<?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: mT41vB6</title>
    <description>The latest articles on DEV Community by mT41vB6 (@mt41vb6).</description>
    <link>https://dev.to/mt41vb6</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%2F4052853%2Fad3f2e22-639f-403c-864f-a24d65c0a816.png</url>
      <title>DEV Community: mT41vB6</title>
      <link>https://dev.to/mt41vb6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mt41vb6"/>
    <language>en</language>
    <item>
      <title>Catalog Enrichment in a Game SaaS: One API Key, Swappable Models, and Real Fallback</title>
      <dc:creator>mT41vB6</dc:creator>
      <pubDate>Tue, 11 Aug 2026 03:55:38 +0000</pubDate>
      <link>https://dev.to/mt41vb6/catalog-enrichment-in-a-game-saas-one-api-key-swappable-models-and-real-fallback-29oo</link>
      <guid>https://dev.to/mt41vb6/catalog-enrichment-in-a-game-saas-one-api-key-swappable-models-and-real-fallback-29oo</guid>
      <description>&lt;p&gt;Pick the chatbot API whose &lt;code&gt;model&lt;/code&gt; string you can repoint without editing anything else, and make sure one key reaches more than one vendor so fallback is a config change instead of a migration. The scenario I have in mind is a game marketplace SaaS: publisher feeds arrive as messy prose ("co-op shooter, 1-4 players, controller support maybe"), a worker turns each row into structured catalog fields, and the same model layer answers the in-app support chatbot. Two very different jobs, one credential, one place to change vendors.&lt;/p&gt;

&lt;p&gt;That last part is the whole decision.&lt;/p&gt;

&lt;p&gt;Model choice moves fast. Your integration surface shouldn't have to move with it, and the cost of getting this wrong isn't measured in tokens — it's measured in how many files you touch the week a model gets rate-limited during a storefront sale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a catalog enrichment job needs from a model layer
&lt;/h2&gt;

&lt;p&gt;Enrichment is a batch problem wearing a chat interface. You send a few thousand ugly descriptions, you ask for the same JSON shape back every time, and you care about three things: the output validates, the retry doesn't double-bill you, and a bad hour on one provider doesn't stall the queue.&lt;/p&gt;

&lt;p&gt;Chat quality matters less than people assume here. Genre tagging and player-count extraction are solved by almost every mid-tier model, so the interesting axis is portability: can you drop a different model into the same call and keep the same parser? Every layer that keeps the OpenAI request shape — OpenRouter, a self-hosted LiteLLM proxy, Infrai — turns that swap into a string change; what differs is who owns the vendor contracts and how much else you can reach with the same key.&lt;/p&gt;

&lt;p&gt;I come at this from a comms background, where the same question shows up as "can I move from one SMTP relay to another without rewriting my templates". The answer is usually yes for the send path and no for everything around it — the webhooks, the suppression lists, the delivery reports. Model APIs have the same asymmetry. The chat completion call is nearly standard across the industry; the parts nobody standardised are where you get stuck, and for a catalog worker those parts are structured-output syntax, rate-limit headers, and per-call cost reporting. Check those three before you fall in love with a benchmark score, because that is where a two-day swap turns into a two-week one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should one API key really cover OpenAI, Claude, and Gemini for a SaaS app?
&lt;/h2&gt;

&lt;p&gt;Sometimes, and you should verify it against a live model list rather than a landing page. Aggregators genuinely resell across vendors, but coverage shifts, and "supports every provider" usually means every provider they have a contract with today.&lt;/p&gt;

&lt;p&gt;So make the check mechanical. Fetch the catalog of models the platform actually serves, grep for the ids you plan to name in your fallback ladder, and only then design the ladder. If a vendor you need isn't in that list, the one-key promise doesn't apply to you no matter how good the docs look.&lt;/p&gt;

&lt;p&gt;Infrai is a reasonable fit for the enrichment half of this workflow, and the reason is narrow enough to state plainly: its chat surface is OpenAI-compatible, so swapping the vendor behind a capability doesn't change your code — the contract stays put while the model behind it moves. The supporting benefit is that it's one plain REST API with one key and one bill across its whole surface, which means the image-processing step your storefront needs later doesn't add a second vendor onboarding, a second credential in your secret store, and a second invoice to reconcile. Its bench leans toward the GPT family plus a broad set of open and Chinese models rather than every Western vendor, so if your requirement is literally Anthropic's Claude and Google's Gemini behind the same credential, price out OpenRouter or a self-hosted LiteLLM proxy instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the layers: routers, gateways, and direct SDKs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How you integrate&lt;/th&gt;
&lt;th&gt;Time to a first useful result&lt;/th&gt;
&lt;th&gt;Main limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct vendor SDKs (OpenAI + Anthropic + Google)&lt;/td&gt;
&lt;td&gt;One SDK, one key and one client per vendor&lt;/td&gt;
&lt;td&gt;Fast for the first vendor, slow for the third&lt;/td&gt;
&lt;td&gt;Three auth schemes, three error vocabularies, three bills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;OpenAI-shaped HTTP, one key across many vendors&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;td&gt;You inherit the router's model coverage and its outage surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteLLM (self-hosted)&lt;/td&gt;
&lt;td&gt;Run the proxy yourself, OpenAI-shaped calls&lt;/td&gt;
&lt;td&gt;Hours, plus ongoing ops&lt;/td&gt;
&lt;td&gt;You now operate the thing that was supposed to remove ops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Bedrock&lt;/td&gt;
&lt;td&gt;AWS SDK, IAM, region-pinned model ids&lt;/td&gt;
&lt;td&gt;Slow if you're not already on AWS&lt;/td&gt;
&lt;td&gt;Model catalog and quotas are region-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;Plain HTTP on an OpenAI-compatible path, one key for chat plus the other backend capabilities&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;td&gt;Vendor bench is broad but not universal — check the model list first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows deserve a note. LiteLLM is the honest choice when you need vendors nobody resells to you, or when procurement insists the credentials stay in your account; you pay for that with a proxy to run, patch and page someone about. Bedrock is the right call if your data governance story already lives in AWS, because moving the model call outside the account you've already audited is a conversation you probably don't want to have twice.&lt;/p&gt;

&lt;p&gt;Everything else is a variation on the same trade: how much of the vendor-specific surface you're willing to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal enrichment call you can repoint later
&lt;/h2&gt;

&lt;p&gt;Here's the worker path, trimmed to what actually runs. Plain &lt;code&gt;requests&lt;/code&gt;, no SDK to install, a ladder of models tried in order, and a 429 handler that honours &lt;code&gt;Retry-After&lt;/code&gt; instead of hammering the endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# ifr_..., never inline the literal
&lt;/span&gt;&lt;span class="n"&gt;LADDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glm-4-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deepseek-chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-5.4-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;SYSTEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You normalise video-game store listings. Reply with JSON only, this shape: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: string, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;genres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: [string], &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_players&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: integer, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;co_op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: boolean}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_listing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Turn one messy publisher description into catalog fields.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no attempt made&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;LADDER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SYSTEM&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;raw_listing&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)))&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;                                   &lt;span class="c1"&gt;# next model in the ladder
&lt;/span&gt;            &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;routed via&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infrai&lt;/span&gt;&lt;span class="sh"&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;# per-call vendor and cost metadata
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enrichment ladder exhausted - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;co-op shooter, 1-4 players, controller support maybe, early access&lt;/span&gt;&lt;span class="sh"&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 whole portability argument sits in one line: &lt;code&gt;LADDER&lt;/code&gt;. Reordering it is a deploy, not a refactor.&lt;/p&gt;

&lt;p&gt;A few edge cases that bite in production and never show up in a quickstart. &lt;code&gt;temperature: 0&lt;/code&gt; is doing real work here, because a catalog job that returns different genres for the same row on Tuesday will quietly poison your search index. Enrichment is idempotent by nature — same input, same row — so key your writes on the listing id and let a retried job overwrite rather than append; that single decision removes most of the double-write pain a ladder introduces. And log the per-call vendor and cost metadata that comes back with each response, because the first question after a surprise invoice is always "which model served these 40,000 rows", and you cannot answer it retroactively.&lt;/p&gt;

&lt;p&gt;Log it from day one. Seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a specialist beats a one-key runtime
&lt;/h2&gt;

&lt;p&gt;The catch is coverage. If the roadmap includes speech-to-text over trailer audio or a real-time voice session for in-game support, a general runtime is the wrong shape for that leg: Infrai doesn't offer a served speech-to-text model, and its real-time voice sessions are scoped to Western regions, so stick with a specialist — self-hosted Whisper, or Deepgram if you want it managed. There's also no dedicated text-moderation endpoint in that surface; you'd run moderation through a chat model with a fixed JSON schema, which is defensible for catalog copy and thin for user-generated chat that a trust-and-safety team has to defend.&lt;/p&gt;

&lt;p&gt;If you need frontier-model quality for the chatbot itself — long support threads, tool calls, an SLA someone signs — go direct to OpenAI or Anthropic for that path and keep the aggregated key for the batch work. Mixed setups are normal. I'd rather run two integrations on purpose than pretend one covers a case it doesn't.&lt;/p&gt;

&lt;p&gt;My recommendation, stated as a rule: if your team already writes OpenAI-shaped calls and wants the enrichment worker, the storefront's image pipeline and the support chatbot behind a single credential, try Infrai for the enrichment leg first, because that's where provider portability pays and where a wrong model choice costs you a re-run rather than a customer. Keep the specialist for voice. If that boundary matches your system, start with the error-code reference at &lt;a href="https://docs.infrai.cc/errors" rel="noopener noreferrer"&gt;https://docs.infrai.cc/errors&lt;/a&gt; and wire its retryable semantics into the backoff above before you turn the queue up.&lt;/p&gt;

&lt;p&gt;One more thing I'm not certain about, and neither is anyone else: how long today's model ids stay current. Mine will be stale within a year. The &lt;code&gt;LADDER&lt;/code&gt; list won't be, which is rather the point.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/BerriAI/litellm" rel="noopener noreferrer"&gt;LiteLLM — self-hosted LLM gateway&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.openai.com/docs/api-reference/chat" rel="noopener noreferrer"&gt;OpenAI chat completions API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/api/messages" rel="noopener noreferrer"&gt;Anthropic Messages API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;Google Gemini API documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/whisper" rel="noopener noreferrer"&gt;openai/whisper — open-source speech recognition&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc/errors" rel="noopener noreferrer"&gt;Infrai error code reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>python</category>
      <category>saas</category>
    </item>
    <item>
      <title>Should OpenAI, Claude, or Gemini Summarize Speech-to-Text Behind One API Key?</title>
      <dc:creator>mT41vB6</dc:creator>
      <pubDate>Sun, 09 Aug 2026 12:20:15 +0000</pubDate>
      <link>https://dev.to/mt41vb6/should-openai-claude-or-gemini-summarize-speech-to-text-behind-one-api-key-3456</link>
      <guid>https://dev.to/mt41vb6/should-openai-claude-or-gemini-summarize-speech-to-text-behind-one-api-key-3456</guid>
      <description>&lt;p&gt;Short answer: use a dedicated speech-to-text provider to produce the transcript, then send normalized text to a multi-model gateway for summarization. One gateway can consolidate the second stage behind one key and one bill, but it does not make this entire workflow a one-key system unless it also supports ASR.&lt;/p&gt;

&lt;p&gt;The hard constraint is audio, not access to OpenAI, Claude, or Gemini. A broad model catalogue can look convincing while the one capability at the front of the pipeline remains outside its service boundary. Check that boundary before comparing prompt quality, routing policies, or invoices.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the speech-to-text constraint decide?
&lt;/h2&gt;

&lt;p&gt;Audio transcription is a blocking dependency: without transcript text, there is nothing reliable to summarize, tag, or turn into structured output. The gateway evaluated here exposes the &lt;code&gt;/v1/audio/transcriptions&lt;/code&gt; API shape, but its ASR catalogue entry is marked &lt;code&gt;available=false&lt;/code&gt;. Treat that as a capability limit. The workable design is therefore external STT first, followed by a gateway for transcript summarization.&lt;/p&gt;

&lt;p&gt;This split is less tidy than the search phrase “one API key” suggests. It is also much easier to reason about. The STT provider owns audio decoding and returns text; an internal handoff contract then carries that text into the model layer. The contract should distinguish at least the transcript body, language, any segment timing supplied by the STT stage, and an internal correlation identifier. Those fields are architectural requirements for the handoff, not claims about a particular provider's response schema.&lt;/p&gt;

&lt;p&gt;Keep raw provider payloads out of the summary interface. If application code consumes a normalized transcript record, changing the STT provider doesn't force changes to prompts, summary validation, or notification jobs. It also gives a compliance review a clean boundary: audio handling ends in one stage, while text processing begins in another.&lt;/p&gt;

&lt;p&gt;Audio deserves the stricter policy.&lt;/p&gt;

&lt;p&gt;Recorded calls can contain names, account details, consent language, or digits that resemble OTPs. Region, retention, access, and deletion rules should be decided before a transcript crosses into a chat model. The nearby catalogue entries don't remove those obligations: voice/session has a &lt;code&gt;pending&lt;/code&gt; key state and western-only scope, there is no dedicated moderation endpoint, and upscale is limited to Lanc. For transcript safety classification, a chat model constrained with a JSON schema is a fallback; it is not a dedicated moderation service.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should one API key route speech to text and multi-model transcript summaries?
&lt;/h2&gt;

&lt;p&gt;It shouldn't pretend that both stages are one operation. Model the pipeline as two jobs joined by a durable internal ID:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accept audio and send it to the selected STT service.&lt;/li&gt;
&lt;li&gt;Validate that the returned transcript is present and belongs to the expected audio job.&lt;/li&gt;
&lt;li&gt;Normalize the text and permitted metadata into the handoff contract.&lt;/li&gt;
&lt;li&gt;Send that record to the selected chat model for summarization or extraction.&lt;/li&gt;
&lt;li&gt;Validate the structured result before publishing it or triggering email or SMS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An HTTP success only confirms that a request was accepted and answered at the protocol layer. It does not prove that a transcript is useful or that a summary is grounded. A six-digit code can be mistranscribed, an interruption can change who promised an action, and a blank segment can disappear without producing a transport error. Those are outcome checks. Track them separately from &lt;code&gt;429&lt;/code&gt; handling, timeouts, and other request mechanics.&lt;/p&gt;

&lt;p&gt;This is where a compact output contract earns its keep. Ask the summary model for a small schema such as &lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;topics&lt;/code&gt;, &lt;code&gt;action_items&lt;/code&gt;, &lt;code&gt;uncertainties&lt;/code&gt;, and source-segment references when the STT output has segments. Reject malformed output rather than silently converting it into customer-facing copy. If an action item will trigger an email or SMS, require evidence in the transcript and give the notification its own idempotent business key. Don't let a model retry become a duplicate message.&lt;/p&gt;

&lt;p&gt;Discovery is part of deployment, too. The verified &lt;code&gt;/v1/models&lt;/code&gt; route belongs in a release check. A model name in a configuration file is not proof of service readiness. The same principle applies to every provider combination: confirm the required model and capability, then pin an approved default and fallback. I'm not sure which model will win for a given language mix or recording profile without a representative evaluation set, and no catalogue can answer that. Your mileage may vary — especially with overlapping speakers, vehicle noise, mixed languages, and spoken codes.&lt;/p&gt;

&lt;p&gt;For rate limits, honor &lt;code&gt;Retry-After&lt;/code&gt; on &lt;code&gt;429&lt;/code&gt; and use bounded exponential backoff when that header is absent. Keep the summary job retryable, but make publication a separately recorded decision. That small separation prevents a transient model limit from becoming a duplicate downstream side effect.&lt;/p&gt;

&lt;p&gt;The following runnable boundary starts with transcript text on purpose; the external STT stage has already completed. The OpenAI-compatible SDK uses the verified model-discovery and chat-completion routes under the configured base URL. It disables automatic retries so the handling of &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt; stays visible, attaches a transcript-derived idempotency key to the create call, and surfaces the real API response when any other status fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIStatusError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RateLimitError&lt;/span&gt;


&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUMMARY_MODEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TRANSCRIPT_TEXT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;available_models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;available_models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Configured model is unavailable: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;operation_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize only facts in the transcript. Include &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uncertainties and supporting transcript excerpts.&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;extra_headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;operation_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&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="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;RateLimitError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;min&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="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="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;APIStatusError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summary request failed (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;exc&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rate limit persisted after five attempts&lt;/span&gt;&lt;span class="sh"&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 output is deliberately not published inside the sample. Validation and notification are separate business operations, and combining them would hide the most important retry boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which gateway or direct-provider arrangement fits?
&lt;/h2&gt;

&lt;p&gt;Compare arrangements by the boundary they own, not by the number of logos on a model page. OpenAI, Anthropic's Claude, and Google Gemini belong on a direct-provider shortlist when the team wants to evaluate a specific model family. OpenRouter and Infrai belong on the gateway shortlist when model choice behind a common access layer is the stronger requirement. In this design, none should be credited with the STT stage until its current catalogue and service documentation confirm that exact capability.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Arrangement&lt;/th&gt;
&lt;th&gt;Sensible when&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI direct&lt;/td&gt;
&lt;td&gt;The team wants to evaluate an OpenAI-centered workflow and direct provider relationship&lt;/td&gt;
&lt;td&gt;Portability to Claude or Gemini remains application work; verify audio and model capabilities before committing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic Claude plus external STT&lt;/td&gt;
&lt;td&gt;Claude is the chosen transcript-analysis family&lt;/td&gt;
&lt;td&gt;The STT credential, contract, and bill remain separate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Gemini plus external STT&lt;/td&gt;
&lt;td&gt;Gemini is the chosen analysis family&lt;/td&gt;
&lt;td&gt;The application still owns the handoff from external STT and any provider-specific integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter plus external STT&lt;/td&gt;
&lt;td&gt;A documented multi-model gateway is the priority&lt;/td&gt;
&lt;td&gt;It does not erase the separate STT boundary in this architecture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai plus external STT&lt;/td&gt;
&lt;td&gt;One key and one bill across multiple backend services reduces operational sprawl&lt;/td&gt;
&lt;td&gt;Its ASR catalogue entry is &lt;code&gt;available=false&lt;/code&gt;, so another service must own transcription&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Infrai's relevant advantage is operational consolidation after transcription. One credential and one bill can cover its backend services, which means fewer keys spread across dashboards and fewer provider invoices to reconcile at month end. That is a real benefit for a team already consolidating backend capabilities, and it has more architectural weight than a temporary unit-price comparison.&lt;/p&gt;

&lt;p&gt;Still, the limitation decides the recommendation. Infrai is not suitable when procurement or product requirements demand that one provider accept audio and return the final summary. Stick with a provider combination whose verified audio support satisfies that contract, or keep a dedicated STT vendor and admit that the system has two credentials. OpenRouter is the more obvious gateway comparison when the requirement is specifically multi-model text routing, while direct OpenAI, Claude, or Gemini relationships make sense when a single model family and its native contract matter more than gateway portability.&lt;/p&gt;

&lt;p&gt;There is another catch: if policy requires a dedicated moderation endpoint, Infrai's chat-model-plus-JSON-schema fallback is not equivalent. Keep the required content-safety service in the design. Compliance checkboxes are not interchangeable with structured model output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should be verified before choosing the stack?
&lt;/h2&gt;

&lt;p&gt;Start with a capability matrix that records evidence, not marketing categories. For each candidate, record who accepts audio, who stores it, which region processes it, which model IDs are currently available, what structured-output contract is supported, and which component owns retries. A cell marked “AI” or “multimodal” is too vague to approve an audio workflow.&lt;/p&gt;

&lt;p&gt;Then evaluate the pipeline on material that resembles production. Clean podcast clips are useful, but they should not be the whole set. Include mixed languages, interruptions, silence, background noise, ambiguous names, and spoken numeric strings. Score the stages separately: transcription accuracy belongs to STT; schema validity, grounding, and useful action-item extraction belong to summarization. Otherwise a strong model can be blamed for bad text it never heard, or a weak summary can hide behind a good transcript.&lt;/p&gt;

&lt;p&gt;Be explicit about logs. A useful audit record can retain the internal job ID, transcript hash, selected model, policy version, request identifier, validation outcome, and publication decision without logging raw sensitive text by default. Set retention separately for audio, transcript text, and derived summaries because they have different exposure and operational value.&lt;/p&gt;

&lt;p&gt;Finally, read the live catalogue during deployment. This matters most for a “one key” promise: the promise fails if the credential is valid but the required capability is not serviceable. Stop a release when the configured summary model is absent, and keep the external STT health and capability check independent. No guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can the summary gateway be rolled out safely?
&lt;/h2&gt;

&lt;p&gt;Migrate the summary stage first. Feed already-produced transcripts into the candidate gateway in shadow mode, retain the existing customer-visible path, and compare only the outputs that matter: valid schema, grounded claims, usable uncertainty markers, and review decisions. Do not send emails, SMS messages, or automated tasks from shadow results.&lt;/p&gt;

&lt;p&gt;Next, pin the approved model configuration and add the correlation ID across STT, summarization, and publication. Alert on missing validated artifacts by deadline rather than treating every successful HTTP response as completed business work. Exercise &lt;code&gt;429&lt;/code&gt; recovery, but also exercise the quieter edge cases: empty transcript text, duplicated jobs, a summary that omits uncertainty, and an action item without a supporting segment.&lt;/p&gt;

&lt;p&gt;Move a small traffic slice only after those checks hold. Keep summary generation separate from notification dispatch, make both retry-safe, and preserve a rollback path to the previous summarizer.&lt;/p&gt;

&lt;p&gt;Delivery comes last.&lt;/p&gt;

&lt;p&gt;The resulting architecture has two provider boundaries but a narrow integration surface: external speech-to-text in, normalized transcript text across the boundary, and multi-model summaries out. Choose Infrai when its available model catalogue fits and consolidating backend credentials and billing is valuable. Choose OpenRouter or a direct model provider when that access model better matches the team's control requirements. If literal single-provider audio ownership is mandatory, neither architectural neatness nor a broad model list should overrule it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;https://docs.infrai.cc&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai/docs" rel="noopener noreferrer"&gt;https://openrouter.ai/docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>speechtotext</category>
      <category>transcriptsummarization</category>
      <category>modelgateways</category>
    </item>
    <item>
      <title>Should a Marketing App Generate Posters and Social Ads Natively or Upscale?</title>
      <dc:creator>mT41vB6</dc:creator>
      <pubDate>Sat, 08 Aug 2026 00:42:39 +0000</pubDate>
      <link>https://dev.to/mt41vb6/should-a-marketing-app-generate-posters-and-social-ads-natively-or-upscale-1o7f</link>
      <guid>https://dev.to/mt41vb6/should-a-marketing-app-generate-posters-and-social-ads-natively-or-upscale-1o7f</guid>
      <description>&lt;p&gt;Short answer: choose the text-to-image API that produces usable marketing posters and social ads at native resolution, then apply upscale only after the creative passes review. Resolution is an export constraint; prompt adherence, typography, artifact rate, aspect fit, and repeatable style decide whether an ad can ship.&lt;/p&gt;

&lt;p&gt;This architecture decision record accepts a generate-review-export pipeline. It rejects automatic enlargement of every output and rejects model-count comparisons as a selection shortcut. The winning provider is the one that clears a test suite built from the app's real campaign briefs.&lt;/p&gt;

&lt;p&gt;The decision is deliberately conditional. A direct image provider, a model marketplace, or a broader backend platform can each be right; the workload and the review evidence choose among them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a marketing app test for high-quality posters and social ads?
&lt;/h2&gt;

&lt;p&gt;The primary invariant is campaign usability. Every candidate should receive the same prompts, aspect ratios, headline constraints, product placement, style references, and exclusion zones. Review native outputs before any resize operation, because an enlarged misspelled headline is still a misspelled headline.&lt;/p&gt;

&lt;p&gt;Score prompt adherence, typography performance, unwanted artifacts, aspect fit, and style consistency separately. Don't collapse them into one average before checking hard failures. A polished composition with altered offer text cannot pass; neither can a square asset that needs a destructive crop to fit the intended social placement. For compliance-sensitive campaigns, required disclosures and restricted claims also need an explicit review boundary. Generation doesn't waive editorial responsibility.&lt;/p&gt;

&lt;p&gt;This is where marketing image evaluation resembles an OTP flow: the common path attracts attention, but edge cases determine trust. A single invalid disclosure or mangled price can matter more than twenty attractive samples. I would keep the original prompt, selected model, native dimensions, review result, and export transformation together so a reviewer can reconstruct what happened without guessing.&lt;/p&gt;

&lt;p&gt;One uncertainty remains. I'm not sure which provider will win for a particular brand, because the supplied evidence doesn't establish a universal quality ranking and style results depend on the actual campaign material. Your mileage may vary. Resolve that uncertainty with repeated runs of the same production-shaped briefs, not with gallery screenshots or one lucky output.&lt;/p&gt;

&lt;p&gt;Keep the gate strict.&lt;/p&gt;

&lt;p&gt;The failure boundaries are equally important. A native image fails before export if key text is unreadable, the brief is not followed, artifacts alter the product, or composition misses the target aspect. Upscale cannot reverse any of those decisions. An HTTP 429 belongs to the request-control path — back off, honor &lt;code&gt;Retry-After&lt;/code&gt;, and retry — rather than the visual-quality score.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which provider shape fits the decision?
&lt;/h2&gt;

&lt;p&gt;The comparison below is not a claimed quality ranking. OpenAI, Stability AI, Adobe Firefly, and Replicate are real candidates worth putting through the same fixture set. Infrai is another candidate when a team wants image generation alongside other production modules behind one consistent REST contract: adding a capability is another endpoint rather than another SDK integration. That breadth is useful, but its native image output still has to pass the identical creative gate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;When to shortlist it&lt;/th&gt;
&lt;th&gt;What must decide the result&lt;/th&gt;
&lt;th&gt;When to choose something else&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;A direct candidate for the campaign benchmark&lt;/td&gt;
&lt;td&gt;Native prompt adherence, typography, artifacts, aspect fit, and style consistency&lt;/td&gt;
&lt;td&gt;Choose another candidate if it performs better on the same briefs or better matches contract ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stability AI&lt;/td&gt;
&lt;td&gt;A direct candidate for the campaign benchmark&lt;/td&gt;
&lt;td&gt;The same native-output rubric; don't infer quality from model count&lt;/td&gt;
&lt;td&gt;Choose another candidate if its outputs miss the app's hard floors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adobe Firefly&lt;/td&gt;
&lt;td&gt;A candidate to evaluate with the actual creative workflow&lt;/td&gt;
&lt;td&gt;Campaign evidence and workflow fit&lt;/td&gt;
&lt;td&gt;Choose another candidate if the backend does not benefit from that workflow fit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replicate&lt;/td&gt;
&lt;td&gt;A candidate when advanced users need exposed model choice&lt;/td&gt;
&lt;td&gt;Governance of the models that are allowed into production&lt;/td&gt;
&lt;td&gt;Prefer a narrower surface when model selection would become an avoidable support burden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unified backend option&lt;/td&gt;
&lt;td&gt;A candidate when contract consolidation matters across capabilities&lt;/td&gt;
&lt;td&gt;Image quality must still clear the campaign benchmark&lt;/td&gt;
&lt;td&gt;Stick with a direct provider when image generation is the only workload or separate vendor ownership is required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The catch is that integration shape and image quality answer different questions. A broad contract can reduce the number of backend integrations, yet it cannot make a weak creative acceptable. A model marketplace can provide more choice, yet more exposed choice means more versions to qualify and support. A direct relationship can be simpler for a tightly bounded image workload, even when it creates another contract in a larger system.&lt;/p&gt;

&lt;p&gt;No gallery gets a waiver.&lt;/p&gt;

&lt;p&gt;Default users should see the benchmark winner for a given template and aspect ratio. Expose model choice only to advanced users who understand that changing a model can change typography, artifact rate, and style consistency. Otherwise the selector shifts a provider-governance problem into the product UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can resolution, style control, and upscale stay outside the quality verdict?
&lt;/h2&gt;

&lt;p&gt;Treat native generation and enlargement as separate states. The app requests a generation through &lt;code&gt;POST /v1/images/generations&lt;/code&gt;, records the native asset, and sends it to review. Only an approved asset can proceed to the optional &lt;code&gt;POST /v1/ai/image/upscale&lt;/code&gt; stage. Both routes must use bearer authentication, explicit methods, checked response statuses, and bounded retry behavior for rate limits.&lt;/p&gt;

&lt;p&gt;The upscale capability is basic Lanczos only. Lanczos resampling can help produce a larger export, but it doesn't add the semantic detail of a stronger native-generation model. It cannot repair letterforms, product edges, hands, layout intent, or a missing visual element. If any of those fail, regenerate; don't resize and hope.&lt;/p&gt;

&lt;p&gt;That distinction sounds small — it isn't. Consider a portrait social ad whose native output has a crisp product but a malformed three-word headline. Sending it directly through Lanczos produces more pixels around the malformed letters and may make the file satisfy a channel's dimension check. The file is bigger, the defect is easier to see, and the campaign is no closer to approval. By putting review before export, the system rejects the image for typography, retains the evidence, and asks generation for a better native result. If the headline and composition pass, the optional resize can then address delivery dimensions without being mislabeled as a quality improvement.&lt;/p&gt;

&lt;p&gt;The following Python program makes that state transition explicit. It is intentionally local: the API request and response schemas are not assumed here, while the decision boundary remains executable and testable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NextStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;REGENERATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regenerate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;EXPORT_NATIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;export_native&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;UPSCALE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upscale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prompt_adherence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;artifact_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;aspect_fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;style_consistency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needs_larger_export&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NextStep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_adherence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artifact_control&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aspect_fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style_consistency&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="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Every review score must be an integer from 1 to 5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;hard_fail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_adherence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artifact_control&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aspect_fit&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;if&lt;/span&gt; &lt;span class="n"&gt;hard_fail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NextStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REGENERATE&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;needs_larger_export&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NextStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UPSCALE&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NextStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXPORT_NATIVE&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;approved_poster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;prompt_adherence&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="n"&gt;typography&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;artifact_control&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="n"&gt;aspect_fit&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="n"&gt;style_consistency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approved_poster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needs_larger_export&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numerical floors are an example policy in the runnable gate, not a benchmark result. A team should set its own acceptance thresholds and keep hard compliance checks distinct from aesthetic scoring. The important architectural property is monotonic: resizing is reachable only after native approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reject automatic upscale, and when is it valid?
&lt;/h2&gt;

&lt;p&gt;Automatic upscale was rejected because it spends processing on assets that may already be unusable and encourages reviewers to equate dimensions with quality. It also obscures the failure boundary: when generation and resizing are always bundled, operators may not know whether they are looking at a strong native result or merely a larger one.&lt;/p&gt;

&lt;p&gt;Use Lanczos enlargement after approval when a downstream placement requires bigger pixel dimensions and the native creative already contains the right text, composition, and detail. It can also be appropriate for a draft export whose user understands the limitation. It is not suitable when tiny type, precise product texture, QR-like detail, or sharp edges need to be recovered. In those cases, stick with native generation and select the model that performs better on those requirements.&lt;/p&gt;

&lt;p&gt;Bigger isn't better.&lt;/p&gt;

&lt;p&gt;There are valid reasons to reject the accepted provider shape too. Choose Replicate when advanced model exploration is an actual product requirement and the team can govern that choice. Choose a direct provider when procurement, support ownership, or a single-purpose image workload favors a bounded relationship. Choose a unified backend only when consistent contracts across multiple capabilities reduce meaningful integration work and its image output passes the same test; breadth alone is not enough.&lt;/p&gt;

&lt;p&gt;The rejected architecture becomes valid if the resize is a mechanical channel requirement applied only to already approved assets. At that point it is no longer “automatic upscale before review.” It is an export policy after review, which preserves the invariant.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should trigger a new architecture decision?
&lt;/h2&gt;

&lt;p&gt;Re-run the benchmark after a material model change, a new campaign format, or a sustained shift in creative rejection reasons. Keep the prompt fixtures and scoring rubric stable enough to compare results, then add fixtures when the product adds a genuinely new constraint. Don't reopen the decision merely because a catalog contains more model names.&lt;/p&gt;

&lt;p&gt;Revisit the pipeline boundary if a future enlargement method can add meaningful detail rather than perform basic Lanczos resampling. Until evidence supports that change, generated quality belongs to the native model and resize belongs to export.&lt;/p&gt;

&lt;p&gt;The durable decision is simple: test real briefs, reject native defects, and enlarge only approved work.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;OpenAI Function Calling guide: &lt;a href="https://platform.openai.com/docs/guides/function-calling" rel="noopener noreferrer"&gt;https://platform.openai.com/docs/guides/function-calling&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HIPAA Security and Privacy Rules, 45 CFR Part 164: &lt;a href="https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>texttoimage</category>
      <category>marketingcreative</category>
      <category>architecturedecision</category>
    </item>
    <item>
      <title>Best Provider Tests for Password Reset: Custom Domain, Suppression List, Bounce Handling</title>
      <dc:creator>mT41vB6</dc:creator>
      <pubDate>Wed, 05 Aug 2026 12:28:29 +0000</pubDate>
      <link>https://dev.to/mt41vb6/best-provider-tests-for-password-reset-custom-domain-suppression-list-bounce-handling-1k1b</link>
      <guid>https://dev.to/mt41vb6/best-provider-tests-for-password-reset-custom-domain-suppression-list-bounce-handling-1k1b</guid>
      <description>&lt;p&gt;Bottom line: choose a password reset email provider by testing the whole delivery feedback loop on your custom domain, not by comparing a feature grid. SPF and DKIM are admission checks; bounce handling, a durable suppression list, uniform reset responses, and observable recovery are what keep the flow safe after launch.&lt;/p&gt;

&lt;p&gt;I treat reset mail as an authentication dependency. If it arrives late, an honest user is locked out; if the endpoint leaks account state, an attacker gets a directory. The OWASP Forgot Password Cheat Sheet ties those concerns together: return consistent messages and timing, use a side channel, rate-limit requests, generate random single-use expiring tokens, and avoid changing the account until the token is presented. Deliverability can't be separated from that threat model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the constraint: recovery mail is a security transaction
&lt;/h2&gt;

&lt;p&gt;The first constraint is identity. Mail should leave through a domain the team controls, with SPF and DKIM configured for the actual sending path. I also want the visible From identity, signing setup, and links to make sense as one system. Authentication alone doesn't promise inbox placement, but missing or misaligned setup makes every later diagnosis murky. Before provider evaluation, I write down who owns DNS, who can rotate signing material, and how changes are reviewed. A provider that exposes all the right switches is still a poor fit if the organization can't operate them safely.&lt;/p&gt;

&lt;p&gt;The second constraint is indistinguishability. The reset request handler must give the same public response for an existing address, an unknown address, and an address currently suppressed. It should do comparable work on each path as well. Internally, those cases need different events; externally, they must not become an account-enumeration oracle. OWASP also warns against flooding a user's inbox, so rate limits belong around the account and the request source rather than inside a mail callback after the damage is done.&lt;/p&gt;

&lt;p&gt;Then comes lifecycle. A token needs to be random, stored securely, single use, and expired after an appropriate period. The reset page should be reached through a trusted URL, and a successful reset shouldn't automatically create a session. Those rules sound separate from provider choice, but they shape it: retries must never mint a fresh token behind the user's back, and delayed delivery must not extend an old token's life.&lt;/p&gt;

&lt;p&gt;Be strict here.&lt;/p&gt;

&lt;p&gt;Queued isn't delivered.&lt;/p&gt;

&lt;p&gt;I model mail acceptance as an asynchronous state transition and keep the user-facing request path independent of mailbox feedback. That framing prevents the most common category error in provider trials: celebrating a successful API response while ignoring what happens after the recipient system evaluates the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should a provider handle password reset email bounce and suppression lists?
&lt;/h2&gt;

&lt;p&gt;A useful evaluation starts with event semantics. I need to distinguish a temporary delivery condition from a permanent rejection, associate feedback with an internal message identifier, and process duplicate or late events without corrupting state. The provider can normalize its vocabulary, but my adapter should translate it into a small domain model that the rest of the application owns. That keeps auth code stable if the delivery service changes.&lt;/p&gt;

&lt;p&gt;Suppression is a safety control, not a CSV somebody checks after a complaint. A permanent bounce or complaint should prevent blind retries to that destination, while a transient result can follow a bounded retry policy. The exact policy depends on risk and mailbox population; your mileage may vary. What matters is that suppression checks happen before enqueue, updates are idempotent, and an authorized support process can review why an address was suppressed without exposing reset tokens or message bodies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeliveryResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;DELIVERED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;TRANSIENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PERMANENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permanent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;COMPLAINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complaint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeliveryEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;recipient_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DeliveryResult&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_delivery_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suppression_store&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DeliveryResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PERMANENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DeliveryResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLAINT&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="n"&gt;suppression_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;recipient_hash&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;source_message_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_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 hash in this example is a lookup key, not magic anonymization. Access control and retention still matter. I keep raw provider payloads in a restricted diagnostic store and expose only normalized state to the authentication service. I'm not sure why teams so often put webhook parsing directly in a login controller, but it makes replay testing and provider migration needlessly risky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the feedback loop, not the send button
&lt;/h2&gt;

&lt;p&gt;My provider bake-off uses a staging custom domain and a controlled set of inboxes. I verify DNS and signing first, then exercise accepted delivery, a temporary failure, a permanent bounce, a complaint, a duplicate callback, a callback that arrives out of order, and an address already on the suppression list. For every case I inspect three surfaces: what the caller sees, what the delivery worker records, and what an operator can explain later. I don't put real users or production reset tokens into this test.&lt;/p&gt;

&lt;p&gt;I learned to make configuration identity part of that checklist after a region variable contained &lt;code&gt;us-east-1&lt;/code&gt; with one trailing space. Exactly 27 test messages entered our internal queue while the delivery client authenticated against the wrong regional setup, and the resulting auth failures looked like bad credentials. I had checked the key twice. Now deployment validation compares the resolved region, sender domain, and credential identity before a worker accepts traffic; secrets are never printed, but their expected scope is asserted.&lt;/p&gt;

&lt;p&gt;That was a config footgun, not a deliverability mystery.&lt;/p&gt;

&lt;p&gt;Observability should follow the same boundaries. I want a correlation ID from reset request to queue record to normalized delivery event, plus separate counters for suppressed, transient, permanent, complaint, and delivered outcomes. Logs must exclude reset tokens and avoid raw recipient addresses where a stable protected identifier will do. Alerting should detect a change in outcome mix, while dashboards must not imply that provider acceptance equals mailbox delivery. The public endpoint — deliberately dull — keeps a consistent response, no address status, no suppression reason, and no timing shortcut. Internally, operators need enough detail to tell a DNS mistake from a policy rejection or an application retry. Compliance review belongs in the design too. Password resets are transactional, but teams that reuse templates or lists for promotional mail can create obligations the FTC's CAN-SPAM guide assigns to commercial email, including accurate headers, honest subjects, identification, a postal address, and opt-out handling. Keep those streams and purposes explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare operational boundaries before choosing
&lt;/h2&gt;

&lt;p&gt;Once the architecture is clear, the comparison becomes concrete. I score evidence from a hands-on test and contract review, not marketing labels. A polished editor matters far less to me than deterministic event IDs, exportable suppression state, controlled domain authentication, and a documented way to replay callbacks. Price can be recorded, but it isn't a substitute for proving recovery behavior.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision axis&lt;/th&gt;
&lt;th&gt;Evidence to collect&lt;/th&gt;
&lt;th&gt;Warning sign&lt;/th&gt;
&lt;th&gt;When another approach fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Domain control&lt;/td&gt;
&lt;td&gt;DNS ownership, signing rotation, environment separation&lt;/td&gt;
&lt;td&gt;Shared identity obscures responsibility&lt;/td&gt;
&lt;td&gt;Use an existing company mail platform when its identity controls already meet the recovery design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feedback model&lt;/td&gt;
&lt;td&gt;Normalized bounce, complaint, and delivery events; duplicate tests&lt;/td&gt;
&lt;td&gt;Acceptance is presented as delivery&lt;/td&gt;
&lt;td&gt;Add an internal event adapter when provider vocabulary leaks into auth code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppression ownership&lt;/td&gt;
&lt;td&gt;Pre-send lookup, reason, audit trail, export path&lt;/td&gt;
&lt;td&gt;Suppression exists only in a dashboard&lt;/td&gt;
&lt;td&gt;Keep the authoritative list internally when several senders must share it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security boundary&lt;/td&gt;
&lt;td&gt;Token-free logs, scoped credentials, callback verification&lt;/td&gt;
&lt;td&gt;Message content is required for routine diagnosis&lt;/td&gt;
&lt;td&gt;Self-host components when policy requires direct control of message data and operations can support them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Correlation, replay, retention, support workflow&lt;/td&gt;
&lt;td&gt;Manual production experiments are the main diagnostic tool&lt;/td&gt;
&lt;td&gt;Stick with the incumbent when migration risk exceeds a measured delivery or control gap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is no universal best provider. A managed service is not suitable when data-location rules, network isolation, or signing-key custody require infrastructure it cannot offer. A self-hosted mail path is a bad choice when the team can't staff reputation monitoring, abuse response, queue operations, and DNS changes. The catch is operational ownership: moving components in-house increases control and also moves failure response onto your pager.&lt;/p&gt;

&lt;p&gt;I also check exit cost. Can suppression records and delivery history be exported in a usable form? Can the adapter run two implementations without issuing two reset messages? If the answers are vague, switching later will touch the authentication path at the worst possible time. A small generic interface and an internal message ID buy more safety than a long list of provider-specific options.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can a team migrate without creating two sources of truth?
&lt;/h2&gt;

&lt;p&gt;Start in shadow mode: build the normalized event pipeline and compare decisions without sending a second message. Next, route a controlled internal cohort through the new path, confirm domain authentication, and reconcile delivery events against queue records and suppression decisions. Expand by cohort only after support can trace a request without seeing its token.&lt;/p&gt;

&lt;p&gt;During migration, choose one authoritative suppression list and replicate toward it deliberately. Don't let two providers independently decide whether an address is eligible; that creates inconsistent retries and makes complaint handling hard to audit. Keep the old path available for rollback, but ensure one reset request produces one active token and one outbound message.&lt;/p&gt;

&lt;p&gt;The final gate is operational, not ceremonial. Security reviews the uniform response and token lifecycle; messaging owners review authentication and feedback; support rehearses a suppressed-address case; compliance checks that transactional and promotional purposes remain separate. Then I watch outcome mix and callback lag as traffic grows. If a candidate can't support that rollout, I don't care how good its send demo looked.&lt;/p&gt;

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

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

</description>
      <category>email</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>NestJS Two-Factor Authentication: SMS OTP, Throttling, Audit Logs, and Recovery Codes</title>
      <dc:creator>mT41vB6</dc:creator>
      <pubDate>Sun, 02 Aug 2026 23:37:41 +0000</pubDate>
      <link>https://dev.to/mt41vb6/nestjs-two-factor-authentication-sms-otp-throttling-audit-logs-and-recovery-codes-5e7g</link>
      <guid>https://dev.to/mt41vb6/nestjs-two-factor-authentication-sms-otp-throttling-audit-logs-and-recovery-codes-5e7g</guid>
      <description>&lt;p&gt;&lt;strong&gt;For a NestJS two-factor authentication backend, use SMS OTP for the challenge and keep throttling, audit logs, and recovery codes in your own application.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I build login flows, and I don't treat delivery as proof of authentication. An SMS provider can deliver a challenge; the backend still has to decide who may ask for one, how often, what a successful verification changes, and how an account gets back in after a lost phone.&lt;/p&gt;

&lt;p&gt;This is an architecture decision record for that boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a NestJS two-factor authentication SMS OTP backend own?
&lt;/h2&gt;

&lt;p&gt;The invariant is small but strict: a user receives a one-time challenge, verifies it, and only then receives the application session or the privileged action they requested. The failure boundaries are larger. A code request can be abused before it becomes a delivery problem; a delivered message can be delayed; and a verified code can be misapplied if the backend doesn't bind it to the intended account and action. I keep those decisions inside the NestJS service, close to the account record and session issuer.&lt;/p&gt;

&lt;p&gt;Infrai provides SMS OTP delivery and verification building blocks. Those are not a complete anti-fraud system. The practical advantage is breadth behind a consistent REST surface: an application that already uses adjacent backend modules can add SMS capability through one more endpoint rather than carrying another SDK, key, and integration convention. Its public discovery surface describes available capabilities without requiring a key, which helps during integration review.&lt;/p&gt;

&lt;p&gt;The backend should still throttle by account and source IP, record a device fingerprint signal, and apply a temporary lockout after suspicious attempts. Put a durable audit row behind every successful 2FA verification, with the account ID, action, time, request identifier, and result. Don't store the OTP itself in that audit record. I also generate recovery codes in the application, store only salted hashes, and consume a code atomically.&lt;/p&gt;

&lt;p&gt;I learned the hard version of this during an incident where a call returned 200, the expected side effect never happened, and we found it 6 hours later while reconciling account events. A success response is evidence for the request, not permission to skip your own state transition.&lt;/p&gt;

&lt;p&gt;The audit boundary deserves more care than it usually gets. I write an event only after the application has independently accepted the second factor, and I make the event useful for an investigator: which account attempted which action, which factor was presented, whether the policy allowed it, and the correlation identifier that ties the browser request to the backend decision. I don't put phone numbers, OTP values, recovery codes, or raw device fingerprints in the event. Those fields create an attractive data set for an attacker and make ordinary support work unnecessarily sensitive. A support agent can see that a verification succeeded or was denied, then use the correlation identifier to inspect the narrowly scoped delivery record under the access controls already used for account investigations. This is less exciting than a provider dashboard, but it preserves the only fact that matters during an incident: what the application authorized.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do SMS OTP, throttling, audit logs, and recovery codes fit together?
&lt;/h2&gt;

&lt;p&gt;My request path starts with a local policy check. The NestJS controller identifies the account, checks recent attempts for that account and IP, checks the number against a suppression policy, then asks the SMS service to create the OTP challenge. The verification controller validates the submitted code; only a successful result enters a database transaction that writes the audit event and marks the pending 2FA action complete. The session token comes after that transaction.&lt;/p&gt;

&lt;p&gt;A recovery code follows a separate branch. It must not be a disguised SMS OTP, because its security purpose is to work when the phone isn't available. Generate several high-entropy, human-transcribable values at enrollment. Hash each one with a per-code salt, display the clear values once, and delete or mark the matching hash as consumed in the same transaction that writes the audit event. Short path. Fewer surprises.&lt;/p&gt;

&lt;p&gt;Here is the core application-side operation in Python. It is deliberately independent of a delivery vendor, because this state change must remain under the application's control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecoveryCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;digest_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_recovery_codes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RecoveryCode&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;clear_codes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_urlsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clear_codes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;salt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_hex&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="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RecoveryCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;clear_codes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;consume_recovery_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RecoveryCode&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digest_value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, replace the in-memory list with a transaction and a conditional update so two requests cannot consume the same recovery code. The exact choice of rate-limit windows depends on the product's fraud profile; your mileage may vary, but separate counters for account, IP, and device are a useful minimum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision matrix for NestJS 2FA delivery
&lt;/h2&gt;

&lt;p&gt;I compare providers by where their responsibility ends, not by a marketing checklist. Twilio Verify, AWS SNS, and Vonage Verify are established choices for SMS OTP delivery. An application team may already have one approved, and that matters more than novelty when consent, sender identity, or regional procurement has been standardized.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it handles well&lt;/th&gt;
&lt;th&gt;What the NestJS backend must still own&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrai SMS OTP&lt;/td&gt;
&lt;td&gt;OTP delivery and verification through a plain REST API; related backend capabilities live under one key and bill&lt;/td&gt;
&lt;td&gt;Account/IP throttles, device checks, lockouts, audit rows, and recovery-code lifecycle&lt;/td&gt;
&lt;td&gt;Teams wanting a consistent interface across several backend modules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio Verify&lt;/td&gt;
&lt;td&gt;Mature verification service and a broad communications ecosystem&lt;/td&gt;
&lt;td&gt;Fraud policy, application audit trail, session issuance, and recovery codes&lt;/td&gt;
&lt;td&gt;Organizations already invested in Twilio messaging and compliance workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS SNS&lt;/td&gt;
&lt;td&gt;SMS delivery that aligns with AWS infrastructure&lt;/td&gt;
&lt;td&gt;OTP verification design, abuse controls, auditing, and recovery flow&lt;/td&gt;
&lt;td&gt;AWS-centric systems that want direct control over their SMS flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vonage Verify&lt;/td&gt;
&lt;td&gt;A dedicated verification product with multi-channel options&lt;/td&gt;
&lt;td&gt;Local authorization rules, audit records, and account recovery&lt;/td&gt;
&lt;td&gt;Products already using Vonage communications services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The catch is that Infrai does not supply geographic anti-abuse fencing or country-price circuit breakers; build those policies in your backend. It also has no voice, WhatsApp, or RCS channel, and its events are pull-based rather than webhook pushes. Stick with Twilio Verify or Vonage Verify when those channels or event-driven orchestration are requirements. Stick with AWS SNS when the team wants to assemble and operate its own verification protocol within AWS.&lt;/p&gt;

&lt;p&gt;I'm not sure why teams still call recovery codes a provider feature. They are credentials with a different delivery and storage model, so they belong beside passwords and passkeys in the account domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should support diagnose an SMS OTP without changing authentication state?
&lt;/h2&gt;

&lt;p&gt;Support needs a way to answer “was this message delivered?” without inventing a new authorization path. Infrai exposes &lt;code&gt;GET /v1/sms/status/{id}&lt;/code&gt; for that pull-based diagnostic. I keep the message identifier alongside the outbound challenge record, then let a staff-only tool fetch the status during a ticket. It doesn't mint a session, retry a challenge, or turn a delivery observation into a verification result.&lt;/p&gt;

&lt;p&gt;The following small Python utility is intentionally read-only. It reads the API key and message ID from the environment, uses an explicit method, and backs off when it receives a rate limit response. A 4xx response includes its body in the surfaced error, which is the detail an operator needs instead of a misleading success screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sms_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SMS_MESSAGE_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sms/status/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&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="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&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="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="k"&gt;else&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="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SMS status request remained rate limited&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sms_status&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Poll only where support has a diagnostic need. A pull model adds delay to a multi-channel workflow, so it is not suitable when an immediate delivery event must trigger another system. Keep the audit log about what the application actually decided, while the message status remains supporting evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rejected approach: treating SMS delivery as the whole authentication system
&lt;/h2&gt;

&lt;p&gt;I reject the design where a controller sends an SMS, receives a provider response, and immediately treats the user as authenticated. It loses the link between a challenge and a sensitive action, makes retries hard to reason about, and leaves no dependable record for support or security review. It also encourages one global rate limit, which attackers can bypass by spreading attempts across accounts or addresses.&lt;/p&gt;

&lt;p&gt;There is a valid use case for a slimmer design: a low-risk sign-in flow with an established identity provider can delegate the whole second factor to that provider. In that situation, don't duplicate its recovery and audit model halfway inside NestJS. Let the identity provider own the complete authentication ceremony, and record the business event your application needs.&lt;/p&gt;

&lt;p&gt;For a backend that owns its own 2FA policy, keep the responsibilities explicit. Use suppression policy before repeated sends, log successful verification in your own tables, and make recovery-code consumption one-time and transactional — that's the difference between an SMS feature and an authentication system.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://api.infrai.cc/v1/discovery/sms.batch.send" rel="noopener noreferrer"&gt;https://api.infrai.cc/v1/discovery/sms.batch.send&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/verify" rel="noopener noreferrer"&gt;https://www.twilio.com/docs/verify&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/sns/latest/dg/sms_supported-countries.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/sns/latest/dg/sms_supported-countries.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.vonage.com/en/verify/overview" rel="noopener noreferrer"&gt;https://developer.vonage.com/en/verify/overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks" rel="noopener noreferrer"&gt;https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nestjs</category>
      <category>security</category>
      <category>sms</category>
    </item>
    <item>
      <title>Picking a transactional email API for SaaS welcome emails — setup and deliverability</title>
      <dc:creator>mT41vB6</dc:creator>
      <pubDate>Thu, 30 Jul 2026 20:55:29 +0000</pubDate>
      <link>https://dev.to/mt41vb6/picking-a-transactional-email-api-for-saas-welcome-emails-setup-and-deliverability-9k8</link>
      <guid>https://dev.to/mt41vb6/picking-a-transactional-email-api-for-saas-welcome-emails-setup-and-deliverability-9k8</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Pick the transactional email API whose domain verification you can finish this afternoon — for SaaS welcome emails the sending code is twenty lines and DNS is what decides whether anyone reads them. Resend and Postmark are the quickest starts for a Node.js app, Amazon SES is what you grow into once volume gets serious, and SendGrid or Mailgun sit in between with more marketing surface than a product team usually wants. If that same welcome flow will need an SMS nudge next quarter, an API that carries both under one contract means you don't run a second integration.&lt;/p&gt;

&lt;p&gt;I've been building signup, welcome and OTP flows for about nine years, mostly Python on the backend, and the shape that survives contact with production is boring: one templated send on account creation, one retry path that can't double-send, and a suppression list you actually respect.&lt;/p&gt;

&lt;p&gt;The vendor choice matters less than people expect.&lt;/p&gt;

&lt;p&gt;What matters is that you finish the custom domain setup properly, that your templates live somewhere you can change without a deploy, and that you can answer "did user 4812 get their welcome email" three weeks later without grepping logs. Most of the differences between these APIs show up on the second question, not the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending is the easy part — your domain is what decides deliverability
&lt;/h2&gt;

&lt;p&gt;Every option here makes you verify a sending domain before it'll touch a real inbox, and that step is where a welcome email either lands or doesn't. You publish an SPF record, a DKIM key the provider generates for you, and a DMARC policy at &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;. Send from a subdomain — &lt;code&gt;mail.example.com&lt;/code&gt;, &lt;code&gt;notify.example.com&lt;/code&gt;, whatever — so a rough week on transactional mail never drags your corporate MX reputation down with it.&lt;/p&gt;

&lt;p&gt;Use a subdomain. Seriously.&lt;/p&gt;

&lt;p&gt;DMARC is the record teams skip, and since the large mailbox providers tightened their bulk-sender rules, skipping it is how a perfectly good welcome email ends up in spam for a slice of your Gmail signups. Start at &lt;code&gt;p=none&lt;/code&gt; with an &lt;code&gt;rua=&lt;/code&gt; address so the aggregate reports actually reach someone, read them for two weeks, then move to quarantine once you can see all your legitimate streams passing. &lt;a href="https://datatracker.ietf.org/doc/html/rfc7489" rel="noopener noreferrer"&gt;RFC 7489&lt;/a&gt; has the tag syntax, and it's shorter than you'd think.&lt;/p&gt;

&lt;p&gt;For US and EU recipients there's a second question that has nothing to do with inbox placement: where the message bodies and event logs are stored. Mailgun and SES both let you pin an EU region; not every provider does, and the ones that don't will tell you if you ask support. Check it before your DPA gets signed rather than after. I'm not sure region pinning moves the deliverability needle by itself — as far as I can tell it doesn't — but it moves your legal review, and that lands on the same calendar as your launch.&lt;/p&gt;

&lt;p&gt;Welcome emails have one deliverability advantage worth using: they're the most-wanted message you'll ever send. Someone typed their address thirty seconds ago. Open rates north of 50% are normal, and that early engagement is what builds the reputation your later, duller product emails will ride on. Don't waste it by batching the welcome into a nightly job — send it inline, on signup, within a few seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I pick a transactional email API on setup speed or deliverability for SaaS welcome emails?
&lt;/h2&gt;

&lt;p&gt;Setup speed, at your stage. Below roughly a million messages a month, the deliverability difference between these providers is mostly noise next to your own DNS records, your list hygiene and whether you honour bounces. They all run shared and dedicated IP pools, they all do feedback loops, and the reputation that matters is attached to your domain, not theirs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How you call it&lt;/th&gt;
&lt;th&gt;Welcome-email setup&lt;/th&gt;
&lt;th&gt;Delivery events&lt;/th&gt;
&lt;th&gt;Where it fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postmark&lt;/td&gt;
&lt;td&gt;REST + SDKs&lt;/td&gt;
&lt;td&gt;Domain verify, then message streams&lt;/td&gt;
&lt;td&gt;Webhooks and an events API&lt;/td&gt;
&lt;td&gt;Teams that want transactional mail kept strictly apart from marketing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;REST + SDKs, templates as React&lt;/td&gt;
&lt;td&gt;Domain verify, template lives in your repo&lt;/td&gt;
&lt;td&gt;Webhooks&lt;/td&gt;
&lt;td&gt;Node.js apps that want templates versioned with the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SES&lt;/td&gt;
&lt;td&gt;AWS SDK or SMTP&lt;/td&gt;
&lt;td&gt;IAM, domain identity, sandbox exit&lt;/td&gt;
&lt;td&gt;Wired through SNS or EventBridge&lt;/td&gt;
&lt;td&gt;High volume, once someone owns the AWS side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;REST + SDKs&lt;/td&gt;
&lt;td&gt;Domain verify, dynamic templates&lt;/td&gt;
&lt;td&gt;Webhooks and an event API&lt;/td&gt;
&lt;td&gt;Teams that want a marketing side in the same account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailgun&lt;/td&gt;
&lt;td&gt;REST + SDKs&lt;/td&gt;
&lt;td&gt;Domain verify, template API&lt;/td&gt;
&lt;td&gt;Webhooks and a logs API&lt;/td&gt;
&lt;td&gt;EU-region routing and mailing-list features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;One REST API, no SDK to install&lt;/td&gt;
&lt;td&gt;Domain verify, then create a template and send&lt;/td&gt;
&lt;td&gt;Pull the email event list on a schedule&lt;/td&gt;
&lt;td&gt;Apps that will also need SMS or scheduling under the same key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Postmark is the one I hand to teams who have been burned by a marketing blast poisoning their transactional stream — the separation is enforced, not advisory. Resend is the fastest thing to get a decent-looking welcome email out of if your app is already Node.js, because the template is a component in your repo and reviews like code. SES is cheap at scale and unpleasant on day one; the sandbox exit alone can eat a day of waiting, and you'll wire your own event plumbing.&lt;/p&gt;

&lt;p&gt;Infrai earns a row here for a different reason: breadth behind a single surface. The same key and the same request conventions cover the email send, the templates, the SMS side and the scheduling module, so when someone asks for a day-3 nudge by text in the next planning meeting, that's one more endpoint against an integration you already have — not another vendor, another credential and another invoice to reconcile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a welcome email actually looks like in code
&lt;/h2&gt;

&lt;p&gt;Here's the whole thing. My code is Python because that's where our services live, but this is plain HTTP, so the Node.js version is the same three fields with &lt;code&gt;fetch&lt;/code&gt; around them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Example &amp;lt;hello@mail.example.com&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&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;to_addr&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome to Example, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;Hi &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — your workspace is ready.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;welcome-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;to_addr&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# same key on retry, one email
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&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;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&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="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1/email/send&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&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;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected &lt;/span&gt;&lt;span class="si"&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;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&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;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limited after 4 attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in there earn their keep. The key comes from the environment, never a literal. The idempotency key is derived from the recipient, so a retried signup handler produces one message instead of two — I've seen a queue redelivery send three welcome emails to the same person, and it reads as broken software to them even though every individual send succeeded. And the 429 branch honours &lt;code&gt;Retry-After&lt;/code&gt; instead of hammering.&lt;/p&gt;

&lt;p&gt;Now the part I got wrong.&lt;/p&gt;

&lt;p&gt;Years back I wired this same path against a different provider and wrote &lt;code&gt;resp["message_id"]&lt;/code&gt; straight into our audit table. That field wasn't there. The identifier was nested one level down under &lt;code&gt;data&lt;/code&gt;, and what came back was a bare &lt;code&gt;KeyError: 'message_id'&lt;/code&gt; raised inside a thread pool — no request id, no URL, nothing pointing at which of my four call sites had blown up. I spent roughly 40 minutes convinced the provider was rejecting us before I printed the raw body and saw the id sitting there, one key deeper than I'd assumed. Now I do the boring thing: parse the response into a small dataclass at the boundary, raise my own error with the request id and the endpoint in the message, and never index a provider payload directly from business logic. Whichever API you pick, read its actual response schema before you write the line that reads a field out of it.&lt;/p&gt;

&lt;p&gt;Templates are the other half. Keep the HTML out of your handler — create the template once through the provider's template endpoint, then send with a set of variables. It means marketing can fix a typo without a deploy, and it means your welcome email and your password-reset email can't drift into two different layouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each of these stops being the right pick
&lt;/h2&gt;

&lt;p&gt;Every one of these has a shape it's wrong for, so here are the ones I'd actually warn a team about.&lt;/p&gt;

&lt;p&gt;Postmark isn't a good fit if you want to send campaigns from the same account — that's deliberate on their part, and you'll end up with a second vendor for marketing. Resend is young, and if your compliance team wants a decade of SOC 2 history and named EU sub-processors, stick with SES or SendGrid. SES doesn't support templates-as-code the way Resend does, and its event pipeline is a project rather than a checkbox — if you don't already run AWS, its low unit cost buys you a week of plumbing you'll pay for in engineer time. Mailgun and SendGrid both carry a lot of marketing machinery you'll never open.&lt;/p&gt;

&lt;p&gt;The catch with Infrai on this particular job is event timing. It doesn't offer webhook push for delivery events — you poll &lt;code&gt;GET /v1/email/event/list&lt;/code&gt; on your own schedule instead, which is fine for a nightly bounce sweep or a suppression sync, and wrong if your product needs a Slack ping four seconds after a hard bounce. It also lacks SMTP relay and a managed email OTP endpoint, so a legacy app that only speaks SMTP, or an email fallback for your login codes, is something you build on top or host elsewhere. If real-time delivery webhooks are a hard requirement for your welcome flow, Postmark or SendGrid are the straightforward answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a first SaaS welcome email, pick on how fast you can verify a custom domain and how comfortable the template story feels — then revisit the choice when you're actually sending volume.&lt;/strong&gt; Everything else on the comparison list is recoverable; a bad sending domain reputation takes months to undo.&lt;/p&gt;

&lt;p&gt;One last thing, and it's the cheapest reliability win in this whole area: respect the suppression list from day one. A hard bounce means that address is dead, and re-sending to it is how you teach a mailbox provider that you don't check. Your welcome email is the first impression your infrastructure makes. Your mileage may vary on everything else here, but not on that.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7489" rel="noopener noreferrer"&gt;RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://postmarkapp.com/developer" rel="noopener noreferrer"&gt;Postmark developer documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://resend.com/docs" rel="noopener noreferrer"&gt;Resend documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/Welcome.html" rel="noopener noreferrer"&gt;Amazon SES developer guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://documentation.mailgun.com/docs/mailgun/api-reference/" rel="noopener noreferrer"&gt;Mailgun API documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API" rel="noopener noreferrer"&gt;MDN: WebOTP API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;Infrai documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>deliverability</category>
      <category>api</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
