[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-list-1":3},{"count":4,"next":5,"previous":5,"results":6},18,null,[7,23,36,49,62,75,87,100,113,126,138,151,164,177,191,204,218,230],{"id":8,"body":9,"uuid":10,"created_at":11,"updated_at":12,"brand":13,"header":14,"short_body":15,"image":16,"published":17,"published_at":18,"tags":19},31,"\u003Cp>Your application needs to receive email.\u003C\u002Fp>\n\u003Cp>Maybe you are building a support platform, processing invoices, tracking customer replies, or creating a unique email address for every project in your SaaS.\u003C\u002Fp>\n\u003Cp>Normally, receiving email means running a mail transfer agent, exposing port 25, configuring MX records, handling MIME messages, validating senders, storing attachments, and monitoring the server.\u003C\u002Fp>\n\u003Cp>That becomes a problem when your hosting provider blocks SMTP traffic—or when your engineering team simply does not want to operate an internet-facing mail server.\u003C\u002Fp>\n\u003Cp>The practical alternative is to separate \u003Cstrong>email transport\u003C\u002Fstrong> from \u003Cstrong>application processing\u003C\u002Fstrong>:\u003C\u002Fp>\n\u003Col>\n\u003Cli>An inbound email provider accepts the SMTP connection.\u003C\u002Fli>\n\u003Cli>The provider parses and stores the message.\u003C\u002Fli>\n\u003Cli>Your application receives the email as a signed HTTPS webhook.\u003C\u002Fli>\n\u003Cli>Your code processes the message like any other API event.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Postwing’s inbound email feature implements this architecture. You point a receiving hostname at Postwing, create address routes, and receive parsed messages through \u003Ccode>inbound.received\u003C\u002Fcode> webhook events.\u003C\u002Fp>\n\u003Ch2>Why blocked SMTP ports cause problems\u003C\u002Fh2>\n\u003Cp>Before choosing a workaround, it is important to distinguish between the common SMTP ports.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Port\u003C\u002Fth>\n\u003Cth>Primary purpose\u003C\u002Fth>\n\u003Cth>Does it receive public internet email?\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>25\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Server-to-server SMTP relay\u003C\u002Ftd>\n\u003Ctd>Yes\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>587\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Authenticated message submission\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>465\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Message submission over implicit TLS\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Port 25 remains the standard port used when one mail server transfers a message to another. Port 587 is intended for message submission, while port 465 is registered for message submission over TLS. Replacing inbound port 25 with port 587 or 465 does not make your server a normal public MX receiver.\u003C\u002Fp>\n\u003Cp>Cloud restrictions vary:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Amazon EC2 restricts outbound port 25 traffic to public addresses by default.\u003C\u002Fli>\n\u003Cli>Google Cloud generally blocks external egress to port 25 but allows ports 465 and 587 unless your own firewall rules block them.\u003C\u002Fli>\n\u003Cli>Azure blocks outbound port 25 for many subscription and platform configurations and recommends authenticated relay services.\u003C\u002Fli>\n\u003Cli>DigitalOcean currently blocks SMTP ports 25, 465, and 587 on Droplets.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Many of these restrictions focus on outbound traffic. However, receiving email yourself still requires an internet-reachable SMTP service, correct firewall rules, a stable public IP, DNS configuration, TLS management, abuse controls, storage, and ongoing server maintenance.\u003C\u002Fp>\n\u003Cp>For most SaaS products, removing SMTP from the application infrastructure is simpler than trying to negotiate port exemptions and operate a mail server.\u003C\u002Fp>\n\u003Ch2>The better architecture: MX in, HTTPS out\u003C\u002Fh2>\n\u003Cp>An inbound email service acts as a bridge between the email network and your application.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">Sender's mail server\n        |\n        | SMTP on port 25\n        v\nPostwing inbound MX\n        |\n        | Parse, authenticate and store\n        v\nSigned HTTPS webhook\n        |\n        v\nYour application\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Your server only needs to accept normal HTTPS traffic. Postwing handles the SMTP-facing side of the system and delivers a structured JSON event to your webhook endpoint.\u003C\u002Fp>\n\u003Cp>This means your application does not need to:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Expose port 25\u003C\u002Fli>\n\u003Cli>Install Postfix, Exim, or another mail transfer agent\u003C\u002Fli>\n\u003Cli>Parse multipart MIME messages\u003C\u002Fli>\n\u003Cli>Maintain an SMTP queue\u003C\u002Fli>\n\u003Cli>Store raw messages and attachments itself\u003C\u002Fli>\n\u003Cli>Implement SMTP-level address rejection\u003C\u002Fli>\n\u003Cli>Keep a mail server IP off blocklists\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>You work with HTTP requests instead of SMTP sessions.\u003C\u002Fp>\n\u003Ch2>How Postwing inbound email works\u003C\u002Fh2>\n\u003Cp>Postwing inbound email is designed for application workflows rather than human mailboxes.\u003C\u002Fp>\n\u003Cp>It does not provide IMAP or POP3 access. Instead, every accepted email is parsed and sent to your code as an \u003Ccode>inbound.received\u003C\u002Fcode> webhook. The event includes:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>the inbound message ID and the receiving domain\u003C\u002Fli>\n\u003Cli>the matched route and the accepted recipient\u003C\u002Fli>\n\u003Cli>the SMTP sender (\u003Ccode>mail_from\u003C\u002Fcode>) and the parsed \u003Ccode>From\u003C\u002Fcode> header\u003C\u002Fli>\n\u003Cli>the \u003Ccode>To\u003C\u002Fcode> and \u003Ccode>Cc\u003C\u002Fcode> header addresses\u003C\u002Fli>\n\u003Cli>the subject, text body, and HTML body\u003C\u002Fli>\n\u003Cli>selected headers (\u003Ccode>Date\u003C\u002Fcode>, \u003Ccode>Reply-To\u003C\u002Fcode>, \u003Ccode>In-Reply-To\u003C\u002Fcode>, \u003Ccode>References\u003C\u002Fcode>, \u003Ccode>Auto-Submitted\u003C\u002Fcode>, \u003Ccode>List-Id\u003C\u002Fcode>, and others)\u003C\u002Fli>\n\u003Cli>DKIM authentication results\u003C\u002Fli>\n\u003Cli>the message size\u003C\u002Fli>\n\u003Cli>attachment metadata\u003C\u002Fli>\n\u003Cli>API links for downloading each attachment and the raw \u003Ccode>.eml\u003C\u002Fcode> file\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Inbound email is a paid-plan feature: the domain’s plan must have both webhooks and inbound routes enabled. Paid plans allow up to 25 routes per domain by default.\u003C\u002Fp>\n\u003Cp>Here is the setup process.\u003C\u002Fp>\n\u003Ch2>Step 1: Choose a receiving hostname\u003C\u002Fh2>\n\u003Cp>Start with a subdomain such as:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>A subdomain is usually the better choice than the root domain.\u003C\u002Fp>\n\u003Cp>For example, suppose your company already receives employee email at:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">alex@example.com\nsupport@example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Changing the MX records for \u003Ccode>example.com\u003C\u002Fcode> could interfere with that existing email service. A dedicated subdomain isolates application email:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">ticket-123@inbound.example.com\nreply-456@inbound.example.com\nupload@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The receiving hostname can be a subdomain of your verified domain, or the verified domain itself. If the domain was connected specifically for receiving—\u003Ccode>help.example.com\u003C\u002Fcode>, for instance—you do not need to add a second label.\u003C\u002Fp>\n\u003Cp>There is one guard: if another provider’s MX record is already published on the domain itself, Postwing refuses it as a receiving hostname and suggests a subdomain instead. That prevents accidentally taking over mail that already flows.\u003C\u002Fp>\n\u003Ch2>Step 2: Publish the MX record\u003C\u002Fh2>\n\u003Cp>Postwing displays the exact DNS record required for your receiving hostname. A typical record looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">inbound.example.com.  IN  MX  10  mx.postwing.app.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This record tells sending mail servers to deliver messages for \u003Ccode>@inbound.example.com\u003C\u002Fcode> to Postwing.\u003C\u002Fp>\n\u003Cp>Three conditions turn receiving on:\u003C\u002Fp>\n\u003Col>\n\u003Cli>The domain is verified through the normal DNS record check (DKIM above all).\u003C\u002Fli>\n\u003Cli>The MX record for the receiving hostname is published and confirmed by Postwing’s DNS check.\u003C\u002Fli>\n\u003Cli>The domain’s plan includes inbound email.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Verification runs automatically. Once the record is confirmed, acceptance begins within a few minutes.\u003C\u002Fp>\n\u003Cp>Postwing recommends making its MX record the only MX record for the receiving hostname.\u003C\u002Fp>\n\u003Cp>You do not need to point your main domain’s mail traffic at Postwing.\u003C\u002Fp>\n\u003Ch2>Step 3: Create a webhook endpoint\u003C\u002Fh2>\n\u003Cp>Create an HTTPS endpoint in your application, for example:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">POST \u002Fwebhooks\u002Fpostwing\u002Finbound\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Then add the endpoint in the Postwing dashboard and explicitly subscribe it to:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">inbound.received\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Inbound events are opt-in. Creating a general webhook endpoint without selecting \u003Ccode>inbound.received\u003C\u002Fcode> will not deliver incoming messages to it—and unlike sending events, an inbound message is not fanned out to every subscribed endpoint. It goes to the single endpoint named by the route that matched (see the next step).\u003C\u002Fp>\n\u003Cp>Postwing signs webhook requests using HMAC-SHA256. Your application should verify the signature over the original request body before parsing or processing the event.\u003C\u002Fp>\n\u003Cp>Every request carries these headers:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">X-Webhook-Event      inbound.received\nX-Webhook-Delivery   delivery identifier, the same value as event_id in the body\nX-Webhook-Timestamp  signing time, unix seconds\nX-Webhook-Signature  HMAC-SHA256, hex encoded\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Step 4: Define inbound routes\u003C\u002Fh2>\n\u003Cp>Routes determine which addresses Postwing accepts and where their messages are delivered.\u003C\u002Fp>\n\u003Cp>Postwing supports three useful route patterns:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Pattern\u003C\u002Fth>\n\u003Cth>Type\u003C\u002Fth>\n\u003Cth>Example matches\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>support\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Exact\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>support@inbound.example.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>ticket-*\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Prefix\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>ticket-91@inbound.example.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>*\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Catch-all\u003C\u002Ftd>\n\u003Ctd>Any address not matched by another route\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Pattern rules:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>A pattern is the local part only—no \u003Ccode>@\u003C\u002Fcode>, no hostname.\u003C\u002Fli>\n\u003Cli>At most one \u003Ccode>*\u003C\u002Fcode>, and only at the end.\u003C\u002Fli>\n\u003Cli>A prefix route requires at least one character where the star is: \u003Ccode>ticket-*\u003C\u002Fcode> matches \u003Ccode>ticket-91\u003C\u002Fcode> but not \u003Ccode>ticket-\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Matching is case-insensitive.\u003C\u002Fli>\n\u003Cli>Each route points at exactly one webhook endpoint belonging to the same domain.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Exact routes have priority, followed by the longest matching prefix and then the catch-all route.\u003C\u002Fp>\n\u003Cp>An email sent to an address that does not match an enabled route is rejected inside the same SMTP transaction: mail for a hostname Postwing does not receive on is refused at \u003Ccode>RCPT TO\u003C\u002Fcode>, and a known hostname with no matching route is refused with \u003Ccode>550\u003C\u002Fcode> after the message data. Either way the sending server generates the bounce, and nothing reaches your application. Postwing does not silently accept mail for undefined addresses.\u003C\u002Fp>\n\u003Cp>This lets you create controlled address namespaces such as:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">support@inbound.example.com\ninvoice@inbound.example.com\nticket-&lt;ticket-id&gt;@inbound.example.com\nproject-&lt;project-id&gt;@inbound.example.com\ncustomer-&lt;customer-id&gt;@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Step 5: Process the inbound webhook\u003C\u002Fh2>\n\u003Cp>A simplified inbound event contains fields such as:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;event_id&quot;: &quot;6f1d2c30-0004-4c7a-9b21-0c8e5a3d7f44&quot;,\n  &quot;event&quot;: &quot;inbound.received&quot;,\n  &quot;inbound_id&quot;: &quot;0f0b6c1a-4a1e-4a3a-9f7a-2c9d1b5e8a10&quot;,\n  &quot;domain&quot;: &quot;example.com&quot;,\n  &quot;route&quot;: &quot;support&quot;,\n  &quot;recipient&quot;: &quot;support@inbound.example.com&quot;,\n  &quot;mail_from&quot;: &quot;alex@customer.example&quot;,\n  &quot;from&quot;: {\n    &quot;email&quot;: &quot;alex@customer.example&quot;,\n    &quot;name&quot;: &quot;Alex&quot;\n  },\n  &quot;to&quot;: [&quot;support@inbound.example.com&quot;],\n  &quot;cc&quot;: [],\n  &quot;subject&quot;: &quot;Problem importing my data&quot;,\n  &quot;text&quot;: &quot;The import stops at 72%.&quot;,\n  &quot;html&quot;: &quot;&lt;p&gt;The import stops at 72%.&lt;\u002Fp&gt;&quot;,\n  &quot;headers&quot;: {\n    &quot;Date&quot;: &quot;Tue, 12 Aug 2025 10:04:11 +0300&quot;,\n    &quot;Reply-To&quot;: &quot;alex@customer.example&quot;\n  },\n  &quot;message_id&quot;: &quot;&lt;a1b2c3@customer.example&gt;&quot;,\n  &quot;auth&quot;: {\n    &quot;dkim&quot;: &quot;pass&quot;,\n    &quot;dkim_aligned&quot;: true\n  },\n  &quot;size&quot;: 18422,\n  &quot;raw_url&quot;: &quot;https:\u002F\u002Fapi.postwing.app\u002Fapi\u002Finbound\u002Fmessages\u002F0f0b6c1a-...\u002Fraw\u002F&quot;,\n  &quot;attachments&quot;: [],\n  &quot;timestamp&quot;: &quot;2025-08-12T07:04:12.481Z&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>raw_url\u003C\u002Fcode> and each \u003Ccode>attachments[].url\u003C\u002Fcode> are stable Postwing API paths, not pre-signed storage links. That is deliberate: the event body is stored and replayed by retries for up to 24 hours, while a signed storage URL expires in minutes. You call the API path with your account credentials, and it redirects to a freshly signed, short-lived download URL.\u003C\u002Fp>\n\u003Ch3>Node.js webhook example\u003C\u002Fh3>\n\u003Cp>The following Express endpoint verifies the Postwing signature and accepts inbound events:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">const crypto = require(&quot;crypto&quot;);\nconst express = require(&quot;express&quot;);\n\nconst app = express();\n\nconst webhookSecret = process.env.POSTWING_WEBHOOK_SECRET;\nconst replayToleranceSeconds = 300;\n\napp.post(\n  &quot;\u002Fwebhooks\u002Fpostwing\u002Finbound&quot;,\n  express.raw({ type: &quot;application\u002Fjson&quot; }),\n  async (req, res) =&gt; {\n    const signature = req.get(&quot;X-Webhook-Signature&quot;);\n    const timestamp = req.get(&quot;X-Webhook-Timestamp&quot;);\n\n    if (\n      !signature ||\n      !timestamp ||\n      !\u002F^[a-f0-9]{64}$\u002Fi.test(signature)\n    ) {\n      return res.sendStatus(400);\n    }\n\n    const timestampNumber = Number(timestamp);\n\n    if (\n      !Number.isFinite(timestampNumber) ||\n      Math.abs(Date.now() \u002F 1000 - timestampNumber) &gt;\n        replayToleranceSeconds\n    ) {\n      return res.sendStatus(400);\n    }\n\n    \u002F\u002F The signature covers:\n    \u002F\u002F &lt;timestamp&gt;.&lt;exact raw request body&gt;\n    const signedPayload = Buffer.concat([\n      Buffer.from(`${timestamp}.`, &quot;utf8&quot;),\n      req.body\n    ]);\n\n    const expectedSignature = crypto\n      .createHmac(&quot;sha256&quot;, webhookSecret)\n      .update(signedPayload)\n      .digest();\n\n    const providedSignature = Buffer.from(signature, &quot;hex&quot;);\n\n    if (\n      providedSignature.length !== expectedSignature.length ||\n      !crypto.timingSafeEqual(\n        providedSignature,\n        expectedSignature\n      )\n    ) {\n      return res.sendStatus(400);\n    }\n\n    let event;\n\n    try {\n      event = JSON.parse(req.body.toString(&quot;utf8&quot;));\n    } catch {\n      return res.sendStatus(400);\n    }\n\n    if (event.event !== &quot;inbound.received&quot;) {\n      return res.sendStatus(204);\n    }\n\n    \u002F\u002F Queue this operation idempotently.\n    \u002F\u002F event.event_id remains the same across webhook retries.\n    await enqueueInboundEmail({\n      idempotencyKey: event.event_id,\n      route: event.route,\n      recipient: event.recipient,\n      sender: event.from?.email,\n      subject: event.subject,\n      text: event.text,\n      html: event.html,\n      attachments: event.attachments,\n      senderAuthenticated:\n        event.auth?.dkim === &quot;pass&quot; &amp;&amp;\n        event.auth?.dkim_aligned === true\n    });\n\n    return res.sendStatus(202);\n  }\n);\n\napp.listen(3000);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Always calculate the signature from the exact raw request bytes. Parsing the body and serializing it again can change whitespace or formatting and invalidate the signature.\u003C\u002Fp>\n\u003Cp>Postwing expects a \u003Ccode>2xx\u003C\u002Fcode> response for successful delivery, and allows 10 seconds for it. Webhook handlers should verify and enqueue the event quickly rather than performing slow attachment processing or AI analysis before responding.\u003C\u002Fp>\n\u003Cp>Failed deliveries are retried on a fixed backoff: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours. Once those attempts are exhausted the delivery is marked failed, but the message itself remains available through the API and the dashboard.\u003C\u002Fp>\n\u003Ch2>Inbound limits\u003C\u002Fh2>\n\u003Cp>Defaults, per message and per domain:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Limit\u003C\u002Fth>\n\u003Cth>Value\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Maximum message size\u003C\u002Ftd>\n\u003Ctd>30 MB\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Attachments per message\u003C\u002Ftd>\n\u003Ctd>up to 25\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Size per attachment\u003C\u002Ftd>\n\u003Ctd>up to 25 MB\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Stored text and HTML bodies\u003C\u002Ftd>\n\u003Ctd>up to 500,000 chars\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Throughput per domain\u003C\u002Ftd>\n\u003Ctd>500 messages\u002Fhour\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>An oversized message is refused at the SMTP layer. When the hourly limit is exceeded, Postwing answers \u003Ccode>450\u003C\u002Fcode> and the sending server retries later. Bodies longer than the stored limit are truncated in the payload; the verbatim \u003Ccode>.eml\u003C\u002Fcode> always keeps the whole message.\u003C\u002Fp>\n\u003Cp>Received messages and their stored files are deleted according to your plan’s log retention period.\u003C\u002Fp>\n\u003Ch2>What can you build with inbound email?\u003C\u002Fh2>\n\u003Cp>Receiving email as structured data supports considerably more than a contact form.\u003C\u002Fp>\n\u003Ch3>1. Email-to-ticket support system\u003C\u002Fh3>\n\u003Cp>Create an exact route:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">support@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>When a customer sends an email:\u003C\u002Fp>\n\u003Col>\n\u003Cli>Postwing accepts and parses it.\u003C\u002Fli>\n\u003Cli>Your webhook receives the message.\u003C\u002Fli>\n\u003Cli>Your application looks up the customer by sender address.\u003C\u002Fli>\n\u003Cli>A support ticket is created.\u003C\u002Fli>\n\u003Cli>Attachments are associated with the ticket.\u003C\u002Fli>\n\u003Cli>Your application sends a confirmation through the normal Postwing sending API.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>This gives customers a familiar support channel while keeping the workflow inside your SaaS.\u003C\u002Fp>\n\u003Ch3>2. Reply-to-ticket addresses\u003C\u002Fh3>\n\u003Cp>Assign each ticket a unique address:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">ticket-8942@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Create a prefix route for:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">ticket-*\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Your webhook can extract \u003Ccode>8942\u003C\u002Fcode> from the recipient and attach the message to the correct ticket.\u003C\u002Fp>\n\u003Cp>This eliminates fragile subject-line matching. The routing identifier is part of the delivery address itself.\u003C\u002Fp>\n\u003Cp>The same pattern works for:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">thread-&lt;id&gt;@inbound.example.com\nconversation-&lt;id&gt;@inbound.example.com\ncase-&lt;id&gt;@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>3. Reply-by-email notifications\u003C\u002Fh3>\n\u003Cp>Suppose your project-management SaaS sends this notification:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">Maria mentioned you in Project Apollo.\nReply to this email to add a comment.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Set the message’s reply address to:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">project-41-thread-987@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>When the user replies, your webhook identifies the project and thread from the recipient address. The email body becomes a new comment.\u003C\u002Fp>\n\u003Cp>Before saving the comment, your application can:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Remove quoted reply history\u003C\u002Fli>\n\u003Cli>Remove signatures\u003C\u002Fli>\n\u003Cli>Check whether the sender belongs to the project\u003C\u002Fli>\n\u003Cli>Detect automated replies\u003C\u002Fli>\n\u003Cli>Preserve attached files\u003C\u002Fli>\n\u003Cli>Store the original \u003Ccode>.eml\u003C\u002Fcode> for auditing\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>4. Invoice and document intake\u003C\u002Fh3>\n\u003Cp>Create an address such as:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">invoices@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Customers or suppliers can email invoices directly to your system.\u003C\u002Fp>\n\u003Cp>Your application can then:\u003C\u002Fp>\n\u003Col>\n\u003Cli>Confirm that the message contains an attachment.\u003C\u002Fli>\n\u003Cli>Download the attachment through the authenticated Postwing API path.\u003C\u002Fli>\n\u003Cli>Scan the file for malware.\u003C\u002Fli>\n\u003Cli>Extract invoice fields.\u003C\u002Fli>\n\u003Cli>Match the sender to a supplier.\u003C\u002Fli>\n\u003Cli>Create an approval workflow.\u003C\u002Fli>\n\u003Cli>Store the raw email as an audit record.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Postwing’s inbound payload identifies each attachment’s ID, filename, MIME type, size, inline status, content ID, and API download path.\u003C\u002Fp>\n\u003Ch3>5. Per-customer ingestion addresses\u003C\u002Fh3>\n\u003Cp>Generate a unique address for every customer:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">customer-a83f9@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>That address can become a simple integration surface. Instead of building an API integration, the customer forwards reports, alerts, receipts, or exported files to their assigned email address.\u003C\u002Fp>\n\u003Cp>Your application knows which tenant owns the message from the recipient identifier.\u003C\u002Fp>\n\u003Cp>This is useful for:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Expense-management platforms\u003C\u002Fli>\n\u003Cli>Bookkeeping applications\u003C\u002Fli>\n\u003Cli>Compliance archives\u003C\u002Fli>\n\u003Cli>Logistics systems\u003C\u002Fli>\n\u003Cli>Property-management software\u003C\u002Fli>\n\u003Cli>Recruitment platforms\u003C\u002Fli>\n\u003Cli>Document-processing products\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Use opaque, non-sequential identifiers when addresses expose tenant or object IDs.\u003C\u002Fp>\n\u003Ch3>6. CRM lead capture\u003C\u002Fh3>\n\u003Cp>Assign an email address to each campaign, partner, or sales representative:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">partner-acme@inbound.example.com\ncampaign-berlin@inbound.example.com\nrep-42@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Incoming messages can automatically create leads and associate them with the correct acquisition source.\u003C\u002Fp>\n\u003Cp>A catch-all route can support dynamically generated addresses, while exact routes can reserve sensitive or operational names.\u003C\u002Fp>\n\u003Ch3>7. Automated report processing\u003C\u002Fh3>\n\u003Cp>Many legacy systems can send email but cannot call a modern REST API.\u003C\u002Fp>\n\u003Cp>Instead of building a custom integration, give the system an inbound address:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">reports-warehouse-7@inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The legacy service sends its scheduled report by email. Your webhook receives the message, downloads the file, validates it, and imports the data.\u003C\u002Fp>\n\u003Cp>Email becomes an adapter between an older system and your application.\u003C\u002Fp>\n\u003Ch3>8. AI-assisted email workflows\u003C\u002Fh3>\n\u003Cp>Because inbound messages arrive as structured JSON, you can pass selected content into an automated classification or extraction pipeline.\u003C\u002Fp>\n\u003Cp>Examples include:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Classifying support requests\u003C\u002Fli>\n\u003Cli>Extracting purchase-order numbers\u003C\u002Fli>\n\u003Cli>Detecting customer intent\u003C\u002Fli>\n\u003Cli>Summarizing long correspondence\u003C\u002Fli>\n\u003Cli>Routing messages to the correct team\u003C\u002Fli>\n\u003Cli>Drafting suggested replies\u003C\u002Fli>\n\u003Cli>Identifying missing invoice fields\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Keep the webhook handler fast. Queue the message first and run expensive processing in a worker.\u003C\u002Fp>\n\u003Cp>Treat email bodies and attachments as untrusted input. They may contain prompt-injection attempts, malicious documents, tracking content, or misleading instructions.\u003C\u002Fp>\n\u003Ch2>Security and reliability checklist\u003C\u002Fh2>\n\u003Cp>Moving SMTP outside your infrastructure reduces operational work, but your application still needs a secure webhook implementation.\u003C\u002Fp>\n\u003Ch3>Verify every webhook signature\u003C\u002Fh3>\n\u003Cp>Do not trust a request simply because it reached an obscure endpoint.\u003C\u002Fp>\n\u003Cp>Validate:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>X-Webhook-Signature\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>\u003Ccode>X-Webhook-Timestamp\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>The exact raw body\u003C\u002Fli>\n\u003Cli>A reasonable replay-protection window\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Use a constant-time comparison for the computed and supplied signatures.\u003C\u002Fp>\n\u003Ch3>Deduplicate with \u003Ccode>event_id\u003C\u002Fcode>\u003C\u002Fh3>\n\u003Cp>Inbound delivery is at least once. A webhook may be retried when your endpoint times out or returns an error.\u003C\u002Fp>\n\u003Cp>Store \u003Ccode>event_id\u003C\u002Fcode> as an idempotency key so the same event cannot create multiple tickets, comments, invoices, or leads. The value does not change across retries.\u003C\u002Fp>\n\u003Ch3>Do not trust the visible sender automatically\u003C\u002Fh3>\n\u003Cp>The \u003Ccode>From\u003C\u002Fcode> header can be forged.\u003C\u002Fp>\n\u003Cp>Postwing reports the DKIM result and whether the signing domain aligns with the visible \u003Ccode>From\u003C\u002Fcode> domain. \u003Ccode>auth.dkim\u003C\u002Fcode> has five values:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Value\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>pass\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>A signature verified\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>fail\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>A signature did not verify\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>none\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>No signature at all—normal for plenty of legitimate mail\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>temperror\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>DNS did not answer, so nothing could be checked\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>permerror\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>The signature or its key is malformed\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>\u003Ccode>temperror\u003C\u002Fcode> and \u003Ccode>permerror\u003C\u002Fcode> are kept separate from \u003Ccode>fail\u003C\u002Fcode> on purpose: “this message is forged” and “we could not check” are different facts, and a rule that drops everything except \u003Ccode>pass\u003C\u002Fcode> should account for both.\u003C\u002Fp>\n\u003Cp>There is no \u003Ccode>spf\u003C\u002Fcode> field and no \u003Ccode>dmarc\u003C\u002Fcode> field in the payload, also by design. SPF is not evaluated in this path, and deriving a DMARC verdict from DKIM alone would produce false failures for SPF-aligned mail.\u003C\u002Fp>\n\u003Cp>For workflows that require stronger sender confidence, treat a sender as authenticated only when:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">event.auth.dkim === &quot;pass&quot; &amp;&amp;\nevent.auth.dkim_aligned === true\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Even then, authorization remains your application’s responsibility. A correctly authenticated sender is not automatically allowed to modify every project, ticket, or account.\u003C\u002Fp>\n\u003Ch3>Authorize from the accepted recipient\u003C\u002Fh3>\n\u003Cp>Use the \u003Ccode>recipient\u003C\u002Fcode> and matched \u003Ccode>route\u003C\u002Fcode> to determine where the message belongs.\u003C\u002Fp>\n\u003Cp>Do not treat every address in the message’s visible \u003Ccode>To\u003C\u002Fcode> or \u003Ccode>Cc\u003C\u002Fcode> headers as an address Postwing accepted. Header recipient lists can include unrelated or manipulated values.\u003C\u002Fp>\n\u003Ch3>Validate attachments\u003C\u002Fh3>\n\u003Cp>Before processing an attachment:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Enforce an application-level size limit\u003C\u002Fli>\n\u003Cli>Allow only required file formats\u003C\u002Fli>\n\u003Cli>Check the real file signature, not only the MIME type\u003C\u002Fli>\n\u003Cli>Scan files for malware\u003C\u002Fli>\n\u003Cli>Generate new storage filenames\u003C\u002Fli>\n\u003Cli>Keep files outside the public web root\u003C\u002Fli>\n\u003Cli>Avoid rendering untrusted HTML directly\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Postwing’s own limits are a maximum message size of 30 MB, up to 25 attachments, and a maximum individual attachment size of 25 MB. Your application may want stricter ones.\u003C\u002Fp>\n\u003Ch3>Prevent email loops\u003C\u002Fh3>\n\u003Cp>Be careful with automatic replies.\u003C\u002Fp>\n\u003Cp>Do not configure an autoresponder that sends responses back to the same inbound route. Postwing bounds a runaway loop by counting \u003Ccode>Received:\u003C\u002Fcode> hops and by the per-domain hourly limit, but that is a backstop against growth, not a substitute for correct autoresponder logic.\u003C\u002Fp>\n\u003Cp>Automated messages and mailing-list traffic are not discarded for you—that is your mail, and the decision is your application’s. Inspect headers such as \u003Ccode>Auto-Submitted\u003C\u002Fcode>, \u003Ccode>Precedence\u003C\u002Fcode>, \u003Ccode>In-Reply-To\u003C\u002Fcode>, and \u003Ccode>List-Id\u003C\u002Fcode>; they are included in the event.\u003C\u002Fp>\n\u003Ch2>Inbound email is not a mailbox\u003C\u002Fh2>\n\u003Cp>Postwing inbound email is intended to deliver mail to code.\u003C\u002Fp>\n\u003Cp>It is not a replacement for Gmail, Outlook, or a shared support mailbox used directly by employees. There is no IMAP or POP3 interface, and users do not sign in to read or reply to messages.\u003C\u002Fp>\n\u003Cp>Your application owns the user experience.\u003C\u002Fp>\n\u003Cp>For example:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>A support SaaS displays inbound messages in a ticket timeline.\u003C\u002Fli>\n\u003Cli>A CRM displays them in a contact activity feed.\u003C\u002Fli>\n\u003Cli>An accounting product displays them beside an invoice.\u003C\u002Fli>\n\u003Cli>A project-management platform converts them into comments.\u003C\u002Fli>\n\u003Cli>A document platform displays them as processed uploads.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Replies can be sent separately through Postwing’s email API, SMTP relay, or SDK.\u003C\u002Fp>\n\u003Ch2>Why use an inbound email service instead of self-hosting?\u003C\u002Fh2>\n\u003Cp>Running your own inbound SMTP server gives you complete control, but it also gives you complete responsibility.\u003C\u002Fp>\n\u003Cp>You must handle:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>SMTP listener availability\u003C\u002Fli>\n\u003Cli>MX and reverse-DNS configuration\u003C\u002Fli>\n\u003Cli>TLS certificates and protocol configuration\u003C\u002Fli>\n\u003Cli>MIME parsing edge cases\u003C\u002Fli>\n\u003Cli>Oversized messages\u003C\u002Fli>\n\u003Cli>Address validation\u003C\u002Fli>\n\u003Cli>Queue management\u003C\u002Fli>\n\u003Cli>Retries and temporary failures\u003C\u002Fli>\n\u003Cli>Spam and abuse controls\u003C\u002Fli>\n\u003Cli>Raw-message and attachment storage\u003C\u002Fli>\n\u003Cli>Security updates\u003C\u002Fli>\n\u003Cli>Monitoring and incident response\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For an email infrastructure company, that investment may make sense.\u003C\u002Fp>\n\u003Cp>For a SaaS team whose actual goal is to turn an email into a ticket, comment, invoice, lead, or uploaded document, it usually does not.\u003C\u002Fp>\n\u003Cp>Postwing keeps SMTP at the edge and gives your application the interface developers already know how to operate: a signed JSON webhook over HTTPS.\u003C\u002Fp>\n\u003Ch2>Frequently asked questions\u003C\u002Fh2>\n\u003Ch3>Can I receive email without opening port 25?\u003C\u002Fh3>\n\u003Cp>Yes. Your application does not need to open port 25 when an inbound provider accepts email on your behalf. You point your receiving hostname’s MX record to the provider and receive messages through HTTPS webhooks.\u003C\u002Fp>\n\u003Cp>The provider still uses SMTP to communicate with sending mail servers, but that SMTP connection never reaches your application server.\u003C\u002Fp>\n\u003Ch3>Can I use port 587 instead of port 25 for inbound email?\u003C\u002Fh3>\n\u003Cp>Not for ordinary server-to-server internet delivery.\u003C\u002Fp>\n\u003Cp>Port 587 is designed for authenticated message submission by clients and applications. Mail-server relay continues to use port 25.\u003C\u002Fp>\n\u003Ch3>What about port 465?\u003C\u002Fh3>\n\u003Cp>Port 465 is used for message submission over TLS. It is not a general replacement for an MX server listening for public email delivery.\u003C\u002Fp>\n\u003Ch3>Do I need to install Postfix or Exim?\u003C\u002Fh3>\n\u003Cp>No. Postwing accepts and parses the SMTP message. Your application receives an HTTPS request containing the parsed message data.\u003C\u002Fp>\n\u003Ch3>Can I receive attachments?\u003C\u002Fh3>\n\u003Cp>Yes. Inbound webhook events include attachment metadata—filename, MIME type, size, inline status, and content ID—plus authenticated API paths for retrieving each file. The raw \u003Ccode>.eml\u003C\u002Fcode> message is also available.\u003C\u002Fp>\n\u003Cp>Defaults allow up to 25 attachments of up to 25 MB each, within a 30 MB message.\u003C\u002Fp>\n\u003Ch3>Can I create dynamic email addresses?\u003C\u002Fh3>\n\u003Cp>Yes. Prefix routes such as \u003Ccode>ticket-*\u003C\u002Fcode> can accept addresses including \u003Ccode>ticket-123@inbound.example.com\u003C\u002Fcode>. A catch-all \u003Ccode>*\u003C\u002Fcode> route can accept any address that does not match a more specific route.\u003C\u002Fp>\n\u003Ch3>What happens when my webhook is temporarily unavailable?\u003C\u002Fh3>\n\u003Cp>Postwing retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours. Because delivery is at least once, your application must deduplicate events using \u003Ccode>event_id\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>The message itself is stored either way and stays available through the API and the dashboard.\u003C\u002Fp>\n\u003Ch3>Can I receive email on my main company domain?\u003C\u002Fh3>\n\u003Cp>Technically yes—the receiving hostname may be the verified domain itself. But if that domain already carries company mail, there is no reason to replace its MX records, and Postwing refuses the configuration when it finds another provider’s MX already published there.\u003C\u002Fp>\n\u003Cp>The practical choice is a dedicated subdomain:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">inbound.example.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>It leaves the MX records serving \u003Ccode>example.com\u003C\u002Fcode> untouched.\u003C\u002Fp>\n\u003Ch3>Which plans include inbound email?\u003C\u002Fh3>\n\u003Cp>Inbound email is part of the paid plans; a plan needs both webhooks and inbound routes enabled. It is not available on the free plan.\u003C\u002Fp>\n\u003Ch2>Receive email without operating SMTP infrastructure\u003C\u002Fh2>\n\u003Cp>Blocked SMTP ports do not have to block your product roadmap.\u003C\u002Fp>\n\u003Cp>Instead of exposing port 25 and maintaining a mail server, let Postwing accept incoming email for a dedicated hostname. Your application receives each message as a parsed, signed HTTPS webhook that can be processed using your existing API infrastructure.\u003C\u002Fp>\n\u003Cp>The result is a simpler architecture:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">Configure MX\n    → create a route\n    → verify a webhook\n    → process incoming email\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Use inbound email to create support tickets, accept replies, ingest invoices, capture leads, process reports, or give every customer and application object its own email address.\u003C\u002Fp>","90bafa46-b789-4b81-8591-a03e5f529097","2026-08-07T11:26:04.065591+03:00","2026-08-07T11:29:33.688075+03:00","postwing","How to Receive Emails When SMTP Ports Are Blocked","You do not need to open SMTP ports or run a mail server to receive email in your application. With an inbound email service, messages are accepted for your domain, parsed, and delivered to your application through signed HTTPS webhooks.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_90bafa46-b789-4b81-8591-a03e5f529097\u002FChatGPT_Image_Aug_7_2026_11_24_38_AM_N1otXBO.png",true,"2026-08-07T11:25:54+03:00",[20,21,22],"inbound","receiving emails","smtp",{"id":24,"body":25,"uuid":26,"created_at":27,"updated_at":28,"brand":13,"header":29,"short_body":30,"image":5,"published":17,"published_at":31,"tags":32},22,"\u003Cp>Choosing a \u003Cstrong>transactional email API\u003C\u002Fstrong> is one of those quiet infrastructure decisions that follows your product for years. The wrong choice surfaces as password-reset emails landing in spam, billing receipts that never arrive, and a payment method your finance team can't actually use. This \u003Cstrong>Postwing vs Resend\u003C\u002Fstrong> comparison breaks down how the two platforms differ on deliverability, developer experience, pricing, and — critically — how you pay for the service, so SaaS founders, engineers, and CTOs can make a decision backed by facts rather than marketing copy.\u003C\u002Fp>\n\u003Cp>Both Postwing and Resend target the same job: getting application-generated email (verification links, receipts, alerts, notifications) into the inbox reliably and through a clean API. They take noticeably different approaches. Resend is a well-funded, developer-loved platform with polished tooling and a React-first email story. Postwing is a developer-focused transactional email service whose standout feature is \u003Cstrong>USDC (crypto) payments on Base\u003C\u002Fstrong>, which removes the credit-card and Stripe dependency that blocks many international and crypto-native teams.\u003C\u002Fp>\n\u003Cp>This article is intentionally balanced. We'll credit Resend where it's strong, be honest about where Postwing fits, and give you a decision framework instead of a sales pitch. If you're evaluating a \u003Cstrong>Resend alternative\u003C\u002Fstrong> specifically because card payments or geographic restrictions are a problem, that context will matter most in the pricing and payments sections.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Note on numbers:\u003C\u002Fstrong> Pricing, free-tier limits, and feature availability change frequently. Treat any figures here as directional and \u003Cstrong>verify current pricing on each provider's site (as of 2026)\u003C\u002Fstrong> before committing.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>What Postwing and Resend Actually Do\u003C\u002Fh2>\n\u003Cp>At a high level, both services are SMTP and HTTP API providers for \u003Cem>transactional\u003C\u002Fem> mail — email triggered by a user action or a system event, sent to one recipient at a time. This is distinct from \u003Cem>marketing\u003C\u002Fem> email (newsletters, campaigns, broadcasts), although both platforms can touch that territory.\u003C\u002Fp>\n\u003Cp>A transactional email platform typically handles:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Sending infrastructure\u003C\u002Fstrong> — managed IP pools, queuing, and retries so you don't run your own mail servers.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authentication\u003C\u002Fstrong> — DKIM, SPF, and DMARC setup so mailbox providers trust your domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Deliverability\u003C\u002Fstrong> — reputation management, bounce and complaint handling, and feedback loops.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Developer interface\u003C\u002Fstrong> — REST API, SMTP relay, and SDKs.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Observability\u003C\u002Fstrong> — logs, webhooks for delivery events, and analytics.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Where they diverge is philosophy and packaging. Resend leans into a modern, design-forward developer experience and an ecosystem built around React Email. Postwing leans into operational simplicity for builders who want a no-friction API plus a payment model that works outside the traditional card-and-Stripe world.\u003C\u002Fp>\n\u003Ch3>Quick definition for AI Overviews and featured snippets\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>Postwing vs Resend in one sentence:\u003C\u002Fstrong> Resend is a polished, React-centric transactional email API best for teams already paying with cards, while Postwing is a developer-focused transactional email API that accepts \u003Cstrong>USDC on Base\u003C\u002Fstrong>, making it a strong Resend alternative for international and crypto-native teams that can't or prefer not to use credit cards.\u003C\u002Fp>\n\u003Ch2>Postwing vs Resend: Feature and Pricing Comparison\u003C\u002Fh2>\n\u003Cp>The table below summarizes the practical differences. Where a value depends on the current plan, we keep it general on purpose — confirm specifics directly with each vendor.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Dimension\u003C\u002Fth>\n\u003Cth>Postwing\u003C\u002Fth>\n\u003Cth>Resend\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Primary focus\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Developer transactional email with crypto-friendly billing\u003C\u002Ftd>\n\u003Ctd>Developer transactional email with React-first tooling\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>API style\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>REST + SMTP relay\u003C\u002Ftd>\n\u003Ctd>REST + SMTP relay\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>SDKs\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>HTTP API, language-agnostic; simple snippets\u003C\u002Ftd>\n\u003Ctd>First-class SDKs (Node, Python, etc.)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Email templating\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Bring-your-own HTML \u002F templating\u003C\u002Ftd>\n\u003Ctd>React Email components, strong template story\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Authentication\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Automated DKIM \u002F SPF \u002F DMARC setup\u003C\u002Ftd>\n\u003Ctd>Guided DKIM \u002F SPF \u002F DMARC setup\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Deliverability tooling\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Managed reputation, bounce\u002Fcomplaint handling\u003C\u002Ftd>\n\u003Ctd>Managed reputation, dedicated IPs on higher tiers\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Webhooks &amp; logs\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Delivery events, searchable logs\u003C\u002Ftd>\n\u003Ctd>Delivery events, logs, analytics dashboard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Payment methods\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>USDC (crypto, on Base)\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Credit\u002Fdebit card, standard processors\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Geographic friction\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Low — no card or bank dependency\u003C\u002Ftd>\n\u003Ctd>Card\u002Fprocessor availability dependent\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Free tier\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Available; verify current limits\u003C\u002Ftd>\n\u003Ctd>Available; verify current limits\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Best for\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Crypto-native, international, card-averse teams\u003C\u002Ftd>\n\u003Ctd>Teams comfortable with cards wanting React tooling\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The single row that most often decides this comparison is \u003Cstrong>Payment methods\u003C\u002Fstrong>. Everything else — API ergonomics, deliverability, webhooks — is table stakes that both platforms handle competently. The payment model is where the two genuinely part ways, and it's why so many teams searching for a \u003Cstrong>Resend alternative\u003C\u002Fstrong> end up at Postwing.\u003C\u002Fp>\n\u003Ch2>Developer Experience: API and Integration\u003C\u002Fh2>\n\u003Ch3>Resend's developer experience\u003C\u002Fh3>\n\u003Cp>Resend earns its reputation here. The API is clean, the docs are well organized, and the React Email project gives front-end-heavy teams a comfortable way to build and preview templates as components. If your stack is already React\u002FNext.js and your designers and engineers share a component mindset, Resend's templating ecosystem is a real, tangible advantage. The onboarding is fast, and the SDKs are idiomatic.\u003C\u002Fp>\n\u003Cp>A typical send with an HTTP API looks like this conceptually:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">curl -X POST &quot;https:\u002F\u002Fapi.resend.com\u002Femails&quot; \\\n  -H &quot;Authorization: Bearer $API_KEY&quot; \\\n  -H &quot;Content-Type: application\u002Fjson&quot; \\\n  -d '{\n    &quot;from&quot;: &quot;noreply@yourdomain.com&quot;,\n    &quot;to&quot;: &quot;user@example.com&quot;,\n    &quot;subject&quot;: &quot;Verify your email&quot;,\n    &quot;html&quot;: &quot;&lt;p&gt;Click &lt;a href=\\&quot;https:\u002F\u002Fapp.example.com\u002Fverify?t=abc\\&quot;&gt;here&lt;\u002Fa&gt; to verify.&lt;\u002Fp&gt;&quot;\n  }'\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Postwing's developer experience\u003C\u002Fh3>\n\u003Cp>Postwing keeps the integration deliberately minimal: a REST endpoint plus SMTP relay, language-agnostic snippets, and automated DKIM\u002FSPF\u002FDMARC configuration so you're not hand-editing DNS guesswork. The mental model is \"send a JSON payload, get a message ID, receive webhooks.\" For teams that don't want an opinionated templating framework and prefer to own their HTML, this is a feature, not a gap.\u003C\u002Fp>\n\u003Cp>A minimal Postwing send in Python:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import requests\n\nresp = requests.post(\n    &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Femails&quot;,\n    headers={&quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;},\n    json={\n        &quot;from&quot;: &quot;noreply@yourdomain.com&quot;,\n        &quot;to&quot;: &quot;user@example.com&quot;,\n        &quot;subject&quot;: &quot;Reset your password&quot;,\n        &quot;html&quot;: &quot;&lt;p&gt;Use this link to reset your password: &quot;\n                &quot;&lt;a href='https:\u002F\u002Fapp.example.com\u002Freset?t=abc'&gt;Reset&lt;\u002Fa&gt;&lt;\u002Fp&gt;&quot;,\n    },\n    timeout=10,\n)\nresp.raise_for_status()\nprint(resp.json()[&quot;id&quot;])\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>And the same thing over SMTP, which is useful when you want to drop in a provider without rewriting application code:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import smtplib\nfrom email.mime.text import MIMEText\n\nmsg = MIMEText(&quot;&lt;p&gt;Your receipt is attached.&lt;\u002Fp&gt;&quot;, &quot;html&quot;)\nmsg[&quot;Subject&quot;] = &quot;Payment receipt&quot;\nmsg[&quot;From&quot;] = &quot;billing@yourdomain.com&quot;\nmsg[&quot;To&quot;] = &quot;customer@example.com&quot;\n\nwith smtplib.SMTP(&quot;smtp.postwing.app&quot;, 587) as server:\n    server.starttls()\n    server.login(&quot;apikey&quot;, API_KEY)\n    server.send_message(msg)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>Practical takeaway:\u003C\u002Fstrong> If your differentiator is rich, component-driven templates, Resend's ecosystem is a pull. If you want a thin, predictable API that doesn't impose a templating framework — and you care about how you pay — Postwing's simplicity is the pull.\u003C\u002Fp>\n\u003Ch2>Deliverability: The Part That Actually Matters\u003C\u002Fh2>\n\u003Cp>A \u003Cstrong>transactional email api\u003C\u002Fstrong> is only as good as its inbox placement. A password reset that lands in spam is functionally a broken feature. Both Postwing and Resend invest in the fundamentals, and both can deliver excellent inbox rates when configured correctly. Crucially, deliverability is a shared responsibility — the provider supplies the infrastructure and reputation management, but \u003Cem>your\u003C\u002Fem> configuration and sending behavior determine outcomes.\u003C\u002Fp>\n\u003Cp>The non-negotiables for either platform:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Authenticate every domain.\u003C\u002Fstrong> Configure DKIM, SPF, and align DMARC. According to widely cited industry guidance and Google's and Yahoo's bulk-sender requirements introduced in 2024, proper authentication is now effectively mandatory for reliable delivery.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Separate transactional and marketing streams.\u003C\u002Fstrong> Use different subdomains (e.g., \u003Ccode>mail.yourdomain.com\u003C\u002Fcode> for transactional, \u003Ccode>news.yourdomain.com\u003C\u002Fcode> for marketing) so a marketing reputation problem doesn't poison your password resets.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Warm up gradually\u003C\u002Fstrong> if you're on dedicated IPs and ramping volume.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Honor bounces and complaints.\u003C\u002Fstrong> Suppress hard bounces and act on spam complaints immediately to protect reputation.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Resend provides solid deliverability tooling and offers dedicated IPs on higher tiers, which matters for high-volume senders who want isolated reputation. Postwing provides managed reputation and automated authentication setup that lowers the chance of a misconfigured DMARC record — a common, silent killer of deliverability for smaller teams.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Honest framing:\u003C\u002Fstrong> Neither provider can promise inbox placement, and any vendor that does is overselling. The differentiator between these two is \u003Cem>not\u003C\u002Fem> deliverability for the typical small-to-mid SaaS — both are competent. The differentiator is everything around it: tooling, philosophy, and payments.\u003C\u002Fp>\n\u003Ch2>The Real Differentiator: USDC Payments and Global Access\u003C\u002Fh2>\n\u003Cp>Here's where the \u003Cstrong>Postwing vs Resend\u003C\u002Fstrong> decision becomes genuinely consequential rather than a matter of taste.\u003C\u002Fp>\n\u003Cp>Resend, like most US-based SaaS infrastructure, bills through standard card processors. That's frictionless if you have a corporate card and operate in a supported region. But a meaningful slice of the developer world doesn't:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Founders in countries where international card payments to US SaaS are unreliable, expensive, or blocked.\u003C\u002Fli>\n\u003Cli>Crypto-native teams and Web3 companies that run treasury in stablecoins and prefer not to maintain card rails.\u003C\u002Fli>\n\u003Cli>Builders who've had cards repeatedly declined by foreign processors, or who face currency-conversion overhead on every invoice.\u003C\u002Fli>\n\u003Cli>Privacy-conscious teams that would rather not route infrastructure spend through a card network.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Postwing accepts \u003Cstrong>USDC on Base\u003C\u002Fstrong>, an Ethereum Layer 2 with low fees and fast confirmation. You fund your balance by sending USDC to a deposit address; the backend watches the chain and credits your account after confirmation. There's no card, no Stripe account, no bank intermediary that might reject the transaction.\u003C\u002Fp>\n\u003Cp>For an international founder who has spent hours fighting declined cards just to pay for infrastructure, this isn't a gimmick — it's the difference between being able to use the product at all and not. That's the honest positioning: Postwing isn't claiming to out-engineer Resend's React tooling. It's solving a \u003Cem>payments and access\u003C\u002Fem> problem that traditional providers structurally cannot.\u003C\u002Fp>\n\u003Ch3>When the payment model should drive your decision\u003C\u002Fh3>\n\u003Cp>Choose based on payments when:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Your team holds treasury in stablecoins and wants to pay infrastructure costs the same way.\u003C\u002Fli>\n\u003Cli>You're outside well-served card regions and card billing has been a recurring failure point.\u003C\u002Fli>\n\u003Cli>You want to avoid currency conversion fees and card declines on recurring infrastructure spend.\u003C\u002Fli>\n\u003Cli>Predictable, on-chain settlement matters more to you than a specific templating framework.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>If none of those apply — you have a corporate card, you're in a supported region, and you love React Email — Resend's payment model is a non-issue and you should weigh the comparison on tooling instead.\u003C\u002Fp>\n\u003Ch2>Practical Example: Migrating From Resend to Postwing\u003C\u002Fh2>\n\u003Cp>Suppose you're running on Resend and want to move to a \u003Cstrong>Resend alternative\u003C\u002Fstrong> because card billing keeps failing for your overseas entity. A clean migration looks like this:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Add and verify your domain in Postwing.\u003C\u002Fstrong> Configure the DKIM\u002FSPF records it generates and align DMARC. Keep the existing Resend domain authenticated in parallel during the cutover.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Abstract your email layer.\u003C\u002Fstrong> If you haven't already, wrap sending behind a single internal interface so the provider is one config change, not a code rewrite.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cpre>\u003Ccode class=\"language-python\"># email_provider.py — a thin abstraction so the provider is swappable\nimport requests\n\nclass EmailProvider:\n    def __init__(self, base_url: str, api_key: str):\n        self.base_url = base_url\n        self.api_key = api_key\n\n    def send(self, *, sender: str, to: str, subject: str, html: str) -&gt; str:\n        resp = requests.post(\n            f&quot;{self.base_url}\u002Fv1\u002Femails&quot;,\n            headers={&quot;Authorization&quot;: f&quot;Bearer {self.api_key}&quot;},\n            json={&quot;from&quot;: sender, &quot;to&quot;: to, &quot;subject&quot;: subject, &quot;html&quot;: html},\n            timeout=10,\n        )\n        resp.raise_for_status()\n        return resp.json()[&quot;id&quot;]\n\n# Swap providers by changing base_url + api_key only.\nprovider = EmailProvider(&quot;https:\u002F\u002Fapi.postwing.app&quot;, API_KEY)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Col>\n\u003Cli>\u003Cstrong>Mirror traffic.\u003C\u002Fstrong> Send a small percentage of non-critical transactional mail (e.g., internal notifications) through Postwing first and watch the delivery webhooks and logs.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Move authentication-critical mail last.\u003C\u002Fstrong> Password resets and verification emails are highest-stakes; shift them once you've confirmed inbox placement on the lower-risk traffic.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Re-point your suppression handling.\u003C\u002Fstrong> Ensure hard bounces and complaints from Postwing webhooks feed the same suppression list you used with Resend.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Fund with USDC.\u003C\u002Fstrong> Deposit USDC on Base to your Postwing balance and confirm the credit before you fully cut over.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>This dual-running pattern protects your most important emails and gives you measurable confidence before you flip the switch.\u003C\u002Fp>\n\u003Ch2>Common Mistakes When Choosing a Transactional Email Provider\u003C\u002Fh2>\n\u003Cp>Teams comparing Postwing vs Resend (or any two providers) tend to trip on the same things. Avoid these:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Comparing on price alone.\u003C\u002Fstrong> A few dollars a month is noise next to the cost of a password-reset email landing in spam. Weigh deliverability, support, and — for many teams — \u003Cem>whether you can even pay the bill\u003C\u002Fem> far above sticker price.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring the payment model until checkout.\u003C\u002Fstrong> If your card gets declined or your region isn't supported, the slickest API in the world is useless. Confirm you can actually pay \u003Cem>before\u003C\u002Fem> you build the integration.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping domain authentication.\u003C\u002Fstrong> Sending from an unauthenticated domain on either platform will tank deliverability. DKIM, SPF, and aligned DMARC are not optional in 2026.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mixing marketing and transactional streams on one subdomain.\u003C\u002Fstrong> A campaign complaint spike shouldn't endanger your receipts. Separate the streams.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Hard-coding the provider.\u003C\u002Fstrong> Without an abstraction layer, switching providers becomes a refactor instead of a config change. Build the seam early.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Not handling bounces and complaints.\u003C\u002Fstrong> Continuing to send to addresses that hard-bounced or complained destroys sender reputation fast.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Testing only the happy path.\u003C\u002Fstrong> Verify retries, webhook delivery, and behavior under rate limits — not just a single successful send in the dashboard.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Forgetting observability.\u003C\u002Fstrong> If you can't search logs and trace a specific message's delivery events, debugging \"the customer says they never got it\" becomes guesswork.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Postwing vs Resend: Which Should You Choose?\u003C\u002Fh2>\n\u003Cp>A short decision framework:\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Choose Resend if:\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul>\n\u003Cli>You bill comfortably with a corporate card in a supported region.\u003C\u002Fli>\n\u003Cli>Your team is React\u002FNext.js heavy and wants component-driven email templates.\u003C\u002Fli>\n\u003Cli>You value a large, mature ecosystem and don't need crypto payments.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>\u003Cstrong>Choose Postwing if:\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul>\n\u003Cli>You want or need to pay with \u003Cstrong>USDC on Base\u003C\u002Fstrong> rather than a card.\u003C\u002Fli>\n\u003Cli>You're an international or crypto-native team that's hit card or processor friction.\u003C\u002Fli>\n\u003Cli>You prefer a thin, framework-agnostic API and automated authentication setup.\u003C\u002Fli>\n\u003Cli>You want a straightforward \u003Cstrong>transactional email api\u003C\u002Fstrong> without templating lock-in.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Both are legitimate, competent choices. The deciding factor for most teams isn't a deliverability benchmark — it's whether the payment and access model fits how your company actually operates.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>Is Postwing a good Resend alternative?\u003C\u002Fh3>\n\u003Cp>Yes, particularly if your blocker is payments or geographic access. Postwing covers the core transactional email needs — REST API, SMTP relay, DKIM\u002FSPF\u002FDMARC, webhooks, and logs — and adds \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, which traditional card-billed providers like Resend don't offer. If you're comfortable with cards and want React Email tooling, Resend may suit you better; if card billing is a recurring failure point, Postwing is the stronger fit.\u003C\u002Fp>\n\u003Ch3>What's the main difference between Postwing and Resend?\u003C\u002Fh3>\n\u003Cp>The most consequential difference is billing. Resend bills through standard card processors; Postwing accepts USDC (crypto) on Base, removing the card and Stripe dependency. On core API capabilities and deliverability fundamentals, the two are broadly comparable for typical small-to-mid SaaS volumes.\u003C\u002Fp>\n\u003Ch3>Can I really pay for email with crypto on Postwing?\u003C\u002Fh3>\n\u003Cp>Yes. You fund your balance by sending USDC on the Base network to a deposit address. Postwing's backend watches the chain and credits your account after the transaction confirms. No credit card, bank, or Stripe account is required, which is why it works for teams that traditional processors won't serve.\u003C\u002Fp>\n\u003Ch3>Does paying with USDC affect deliverability?\u003C\u002Fh3>\n\u003Cp>No. The payment method is entirely separate from sending infrastructure. Deliverability depends on domain authentication (DKIM, SPF, DMARC), sender reputation, and sending hygiene — not how you fund your account. USDC simply removes a billing barrier.\u003C\u002Fp>\n\u003Ch3>Which has better deliverability, Postwing or Resend?\u003C\u002Fh3>\n\u003Cp>For most small-to-mid SaaS senders, both deliver competitively when configured correctly, so deliverability is rarely the deciding factor between them. Inbox placement is driven far more by your authentication setup and sending behavior than by the provider. Resend offers dedicated IPs on higher tiers for high-volume isolation; Postwing emphasizes automated authentication setup that reduces misconfiguration risk.\u003C\u002Fp>\n\u003Ch3>How hard is it to migrate from Resend to Postwing?\u003C\u002Fh3>\n\u003Cp>Straightforward if you abstract your email-sending behind a single interface. Then switching providers is mostly a base-URL and API-key change, plus configuring DNS authentication for the new domain and re-pointing bounce\u002Fcomplaint suppression. Run both in parallel and shift your highest-stakes emails (resets, verifications) last.\u003C\u002Fp>\n\u003Ch3>How much does each provider cost?\u003C\u002Fh3>\n\u003Cp>Both offer free tiers and usage-based paid plans, but exact prices and limits change. Rather than rely on a number here, \u003Cstrong>verify current pricing on each provider's site (as of 2026)\u003C\u002Fstrong>. Remember to factor in total cost of access: for some teams, card declines and currency conversion make a \"cheap\" card-only provider effectively more expensive than a USDC-billed one.\u003C\u002Fp>\n\u003Ch3>Is Postwing only for crypto companies?\u003C\u002Fh3>\n\u003Cp>No. While crypto-native teams benefit most obviously, Postwing is built for any developer or SaaS company that wants a clean transactional email api — and the USDC option is especially valuable for international founders who face card or processor friction regardless of whether their own product touches crypto.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>The \u003Cstrong>Postwing vs Resend\u003C\u002Fstrong> decision usually isn't won or lost on raw deliverability or API elegance — both platforms handle the fundamentals of transactional email well. Resend brings a polished, React-first developer experience and a mature ecosystem, and it's an excellent choice for teams that bill comfortably with cards and love component-driven templates. Postwing brings a thin, framework-agnostic \u003Cstrong>transactional email api\u003C\u002Fstrong>, automated authentication, and a payment model — \u003Cstrong>USDC on Base\u003C\u002Fstrong> — that solves a real, structural problem for international and crypto-native teams that traditional card-billed providers can't.\u003C\u002Fp>\n\u003Cp>The right answer depends on your context. If card billing in a supported region is a non-issue and you want React Email tooling, Resend is a strong pick. If you've fought declined cards, currency conversion, or regional restrictions just to pay for infrastructure — or you simply run treasury in stablecoins — Postwing is the \u003Cstrong>Resend alternative\u003C\u002Fstrong> that lets you actually use the product without friction. Authenticate your domains, separate your sending streams, build an abstraction layer, and choose the provider whose payment model matches how your company operates.\u003C\u002Fp>\n\u003Ch2>Get Started With Postwing\u003C\u002Fh2>\n\u003Cp>If you're a developer or SaaS founder who wants reliable transactional email \u003Cem>and\u003C\u002Fem> the freedom to pay with USDC on Base — no cards, no Stripe, no regional roadblocks — Postwing is built for you. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing\u003C\u002Fa>\u003C\u002Fstrong>: add your domain, get automated DKIM\u002FSPF\u002FDMARC setup, fund your balance with USDC, and send your first transactional email in minutes. It's the \u003Cstrong>transactional email api\u003C\u002Fstrong> that meets crypto-native and international teams where they are.\u003C\u002Fp>","9ed3c1dc-608f-4ea8-b889-9c240ee3b596","2026-06-24T18:28:18.079687+03:00","2026-06-24T18:28:18.079698+03:00","Postwing vs Resend: Transactional Email Comparison","","2026-08-07T09:00:00+03:00",[33,34,35],"postwing vs resend","resend alternative","transactional email api",{"id":37,"body":38,"uuid":39,"created_at":40,"updated_at":41,"brand":13,"header":42,"short_body":43,"image":44,"published":17,"published_at":45,"tags":46},21,"\u003Cp>To \u003Cstrong>send millions of emails\u003C\u002Fstrong> per month reliably, you need four things working together: a durable queue that decouples your application from your email provider, rate limiting that respects each provider and ISP, controlled concurrency across workers, and monitoring that catches reputation problems before they cascade. Get those right and a million messages a month — roughly 33,000 a day, or about 23 emails per minute averaged out — is a routine workload, not a crisis.\u003C\u002Fp>\n\u003Cp>The hard part is rarely the raw send volume. One million emails a month is a modest number for any serious mail platform; providers handle billions. The hard part is doing it \u003Cem>without your code falling over during traffic spikes, without burning your sender reputation, and without losing messages when something downstream fails\u003C\u002Fem>. That requires deliberate \u003Cstrong>scalable email infrastructure\u003C\u002Fstrong> rather than a \u003Ccode>for\u003C\u002Fcode> loop that calls an API.\u003C\u002Fp>\n\u003Cp>This guide is a practical, architecture-first walkthrough for engineers and CTOs who need to send millions of emails reliably. We'll cover queueing, rate limits, concurrency, multiple domains and IPs, retries, monitoring, and what it actually costs at scale — with text-described architecture diagrams and runnable code examples. By the end you'll have a concrete blueprint you can implement incrementally.\u003C\u002Fp>\n\u003Ch2>What \"1 Million Emails per Month\" Actually Means\u003C\u002Fh2>\n\u003Cp>Before designing anything, translate the headline number into rates, because rates — not totals — are what break systems.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Metric\u003C\u002Fth>\n\u003Cth>Value (even distribution)\u003C\u002Fth>\n\u003Cth>Realistic peak (10x burst)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Per month\u003C\u002Ftd>\n\u003Ctd>1,000,000\u003C\u002Ftd>\n\u003Ctd>—\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Per day\u003C\u002Ftd>\n\u003Ctd>~33,300\u003C\u002Ftd>\n\u003Ctd>—\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Per hour\u003C\u002Ftd>\n\u003Ctd>~1,390\u003C\u002Ftd>\n\u003Ctd>~13,900\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Per minute\u003C\u002Ftd>\n\u003Ctd>~23\u003C\u002Ftd>\n\u003Ctd>~230\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Per second\u003C\u002Ftd>\n\u003Ctd>~0.4\u003C\u002Ftd>\n\u003Ctd>~4\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Two lessons fall out of this table immediately.\u003C\u002Fp>\n\u003Cp>First, the \u003Cstrong>average\u003C\u002Fstrong> rate is tiny — well under one email per second. If your traffic were perfectly smooth, a single thread could handle it. It never is.\u003C\u002Fp>\n\u003Cp>Second, the \u003Cstrong>peak\u003C\u002Fstrong> is what you must design for. Real transactional traffic is bursty: a product launch, a billing run, a \"your trial ends today\" campaign, or a 9 a.m. wave of password resets can push you to 10x or 50x the average for short windows. Scalable email infrastructure is fundamentally about absorbing those bursts gracefully — accepting work instantly and draining it at a sustainable rate.\u003C\u002Fp>\n\u003Cp>This is the core mental shift: \u003Cstrong>decouple acceptance from delivery.\u003C\u002Fstrong> Your application should hand off an email in microseconds and move on. A separate system delivers it at whatever rate is safe.\u003C\u002Fp>\n\u003Ch2>The Architecture to Send Millions of Emails\u003C\u002Fh2>\n\u003Cp>Here is the reference architecture, described as a diagram in text:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>[ Your App \u002F API ]\n        |\n        v  (enqueue: fast, non-blocking)\n[ Durable Message Queue ]  &lt;-- Redis \u002F RabbitMQ \u002F SQS \u002F Kafka\n        |\n        v  (pull work)\n[ Pool of Email Workers ]  --- concurrency N, rate-limited ---+\n        |                                                     |\n        v                                                     v\n[ Email Provider \u002F SMTP relay ]                        [ Retry \u002F DLQ ]\n        |\n        v  (async webhooks: delivered, bounced, complained)\n[ Event Consumer ] --&gt; [ Metrics + Suppression DB ] --&gt; [ Alerting ]\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The flow, stage by stage:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Application\u003C\u002Fstrong> validates and enqueues a message. It does \u003Cem>not\u003C\u002Fem> call the email provider directly. This call must be fast and must not block a user request.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Durable queue\u003C\u002Fstrong> persists the message so it survives a crash. This is the buffer that absorbs bursts.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Worker pool\u003C\u002Fstrong> pulls messages, applies rate limiting and concurrency control, and calls the provider. Workers scale horizontally.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Email provider \u002F SMTP relay\u003C\u002Fstrong> accepts the message and handles the actual SMTP delivery to recipient mail servers.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retry \u002F dead-letter queue (DLQ)\u003C\u002Fstrong> captures transient failures for backoff retries and permanent failures for inspection.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Event consumer\u003C\u002Fstrong> ingests delivery\u002Fbounce\u002Fcomplaint webhooks, updates metrics, and feeds the suppression list.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Alerting\u003C\u002Fstrong> watches the metrics and pages you before a reputation problem becomes an outage.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Every section below is one piece of this picture.\u003C\u002Fp>\n\u003Ch2>Queueing: The Foundation of Scalable Email Infrastructure\u003C\u002Fh2>\n\u003Cp>A queue is non-negotiable to \u003Cstrong>send millions of emails\u003C\u002Fstrong>. Without one, every email send is coupled to a live HTTP request to your provider — so a provider slowdown becomes a user-facing slowdown, a crash loses in-flight messages, and a burst overruns your rate limits instantly.\u003C\u002Fp>\n\u003Ch3>Why a queue first\u003C\u002Fh3>\n\u003Cp>A durable queue gives you four properties for free:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Burst absorption\u003C\u002Fstrong> — accept 10,000 emails in a second, deliver them over the next ten minutes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Durability\u003C\u002Fstrong> — messages survive worker crashes and deploys; nothing is lost in memory.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Backpressure\u003C\u002Fstrong> — when downstream is slow, work accumulates safely instead of failing.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Decoupling\u003C\u002Fstrong> — your web tier and your delivery tier scale independently.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Choosing a queue\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Queue\u003C\u002Fth>\n\u003Cth>Best for\u003C\u002Fth>\n\u003Cth>Throughput\u003C\u002Fth>\n\u003Cth>Notes\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Redis (RQ\u002FBullMQ\u002FSidekiq)\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Most SaaS at this scale\u003C\u002Ftd>\n\u003Ctd>Very high\u003C\u002Ftd>\n\u003Ctd>Simple, fast, low ops; pair with persistence\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>RabbitMQ\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Complex routing, priorities\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Mature, flexible, more to operate\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>AWS SQS\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Serverless \u002F AWS-native\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Fully managed, built-in DLQ and visibility timeout\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Kafka\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Huge volume, event streaming\u003C\u002Ftd>\n\u003Ctd>Extreme\u003C\u002Ftd>\n\u003Ctd>Overkill for 1M\u002Fmonth; great at 1B\u002Fmonth\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>For one million emails a month, \u003Cstrong>Redis-backed queues or SQS are the sweet spot.\u003C\u002Fstrong> Kafka is over-engineering until you're an order of magnitude larger. Pick the boring option your team already runs.\u003C\u002Fp>\n\u003Ch3>A minimal producer\u002Fconsumer in Python\u003C\u002Fh3>\n\u003Cp>The producer enqueues without ever touching the email API:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import json\nimport redis\n\nr = redis.Redis(host=&quot;localhost&quot;, port=6379, db=0)\nQUEUE_KEY = &quot;email:outbound&quot;\n\n\ndef enqueue_email(to: str, template: str, context: dict) -&gt; None:\n    &quot;&quot;&quot;Called from your app. Fast, non-blocking, never sends directly.&quot;&quot;&quot;\n    job = {&quot;to&quot;: to, &quot;template&quot;: template, &quot;context&quot;: context}\n    r.lpush(QUEUE_KEY, json.dumps(job))\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The worker pulls and sends, with the rate limiting and retries we add below:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import json\nimport time\nimport redis\nimport requests\n\nr = redis.Redis(host=&quot;localhost&quot;, port=6379, db=0)\nQUEUE_KEY = &quot;email:outbound&quot;\nDLQ_KEY = &quot;email:dead&quot;\nPROVIDER_URL = &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend&quot;\nAPI_KEY = &quot;pw_live_xxx&quot;\n\n\ndef send_via_provider(job: dict) -&gt; requests.Response:\n    return requests.post(\n        PROVIDER_URL,\n        headers={&quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;},\n        json=job,\n        timeout=15,\n    )\n\n\ndef worker_loop() -&gt; None:\n    while True:\n        item = r.brpop(QUEUE_KEY, timeout=5)\n        if item is None:\n            continue\n        _, raw = item\n        job = json.loads(raw)\n        try:\n            resp = send_via_provider(job)\n            resp.raise_for_status()\n        except requests.RequestException:\n            r.lpush(DLQ_KEY, raw)  # retry logic refined later\n        time.sleep(0.0)  # rate limiting added below\n\n\nif __name__ == &quot;__main__&quot;:\n    worker_loop()\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This is the skeleton. The next sections turn it into something safe at scale.\u003C\u002Fp>\n\u003Ch2>Rate Limits: Respect the Provider and the ISPs\u003C\u002Fh2>\n\u003Cp>Rate limits exist at two layers, and you must respect both to send millions of emails without getting throttled or blocked.\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Provider rate limits\u003C\u002Fstrong> — your email API enforces a requests-per-second cap (often by plan). Exceed it and you get \u003Ccode>429 Too Many Requests\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>ISP rate limits\u003C\u002Fstrong> — Gmail, Outlook, Yahoo and others throttle \u003Cem>per sending domain\u002FIP reputation\u003C\u002Fem>. Send too fast from a cold or low-reputation sender and they defer or reject you, regardless of what your provider allows.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>The second layer is the subtle one. A provider may happily accept 500 requests\u002Fsecond from you, but Gmail will start deferring (\u003Ccode>4xx\u003C\u002Fcode>) your mail if a new IP suddenly blasts it. \u003Cstrong>Throughput is gated by reputation, not just by API limits.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3>Implementing a token-bucket rate limiter\u003C\u002Fh3>\n\u003Cp>A token bucket is the standard, simple algorithm: tokens refill at a steady rate; each send consumes one; when the bucket is empty, senders wait. Here's a distributed version using Redis so it works across many worker processes:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import time\nimport redis\n\nr = redis.Redis(host=&quot;localhost&quot;, port=6379, db=0)\n\n# Refill 20 tokens\u002Fsec, bucket capacity 40 (allows short bursts).\nRATE = 20.0\nCAPACITY = 40\n\n_TOKEN_BUCKET_LUA = &quot;&quot;&quot;\nlocal key = KEYS[1]\nlocal rate = tonumber(ARGV[1])\nlocal capacity = tonumber(ARGV[2])\nlocal now = tonumber(ARGV[3])\nlocal bucket = redis.call('HMGET', key, 'tokens', 'ts')\nlocal tokens = tonumber(bucket[1]) or capacity\nlocal ts = tonumber(bucket[2]) or now\ntokens = math.min(capacity, tokens + (now - ts) * rate)\nlocal allowed = 0\nif tokens &gt;= 1 then\n  tokens = tokens - 1\n  allowed = 1\nend\nredis.call('HMSET', key, 'tokens', tokens, 'ts', now)\nredis.call('EXPIRE', key, 60)\nreturn allowed\n&quot;&quot;&quot;\n_take = r.register_script(_TOKEN_BUCKET_LUA)\n\n\ndef acquire_token(bucket_key: str = &quot;rl:send&quot;) -&gt; bool:\n    return bool(_take(keys=[bucket_key], args=[RATE, CAPACITY, time.time()]))\n\n\ndef wait_for_token(bucket_key: str = &quot;rl:send&quot;) -&gt; None:\n    while not acquire_token(bucket_key):\n        time.sleep(0.02)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Call \u003Ccode>wait_for_token()\u003C\u002Fcode> immediately before each provider call in the worker. Because the bucket lives in Redis, \u003Cem>all\u003C\u002Fem> workers share one global rate limit — adding workers increases concurrency but not your send rate, which is exactly what you want.\u003C\u002Fp>\n\u003Ch3>Handling \u003Ccode>429\u003C\u002Fcode> from the provider\u003C\u002Fh3>\n\u003Cp>Even with client-side limiting, always honor the provider's \u003Ccode>Retry-After\u003C\u002Fcode> header. It's the authoritative signal:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">def send_with_backoff(job: dict) -&gt; None:\n    while True:\n        resp = send_via_provider(job)\n        if resp.status_code == 429:\n            wait = int(resp.headers.get(&quot;Retry-After&quot;, &quot;1&quot;))\n            time.sleep(wait)\n            continue\n        resp.raise_for_status()\n        return\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Concurrency: Scale Workers Horizontally\u003C\u002Fh2>\n\u003Cp>Concurrency is how you achieve throughput; rate limiting is how you cap it safely. The two work together.\u003C\u002Fp>\n\u003Cp>The right model to send millions of emails is \u003Cstrong>many small workers behind a shared rate limiter\u003C\u002Fstrong>, not one giant multithreaded process. This gives you:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Horizontal scaling\u003C\u002Fstrong> — add worker pods\u002Fcontainers to drain the queue faster (up to your rate limit).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Fault isolation\u003C\u002Fstrong> — one worker crashing doesn't take down delivery.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Simple ops\u003C\u002Fstrong> — autoscale on queue depth; scale to zero when idle.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Sizing the worker pool\u003C\u002Fh3>\n\u003Cp>The math is straightforward:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>required_workers ≈ (target_send_rate × avg_request_latency) \u002F 1\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>If each provider call takes ~200 ms and you want to sustain 20 sends\u002Fsecond:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>workers ≈ 20 × 0.2 = 4 concurrent in-flight requests\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>So \u003Cstrong>a handful of workers\u003C\u002Fstrong> sustains a million emails a month with room to spare. You provision more not for the average but to drain bursts quickly. A common pattern: autoscale worker count based on queue depth.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Queue depth\u003C\u002Fth>\n\u003Cth>Action\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>&lt; 1,000\u003C\u002Ftd>\n\u003Ctd>Run baseline workers (e.g. 2)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>1,000–10,000\u003C\u002Ftd>\n\u003Ctd>Scale to 8 workers\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>&gt; 10,000\u003C\u002Ftd>\n\u003Ctd>Scale to 24 workers (still capped by the shared rate limiter)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Crucially, scaling workers never lets you exceed the global rate limit, because the token bucket is shared. You drain the \u003Cem>backlog\u003C\u002Fem> faster without sending \u003Cem>per-second\u003C\u002Fem> faster than reputation allows.\u003C\u002Fp>\n\u003Ch3>Async concurrency in one process\u003C\u002Fh3>\n\u003Cp>If you prefer fewer processes, an async worker handles many in-flight requests on one thread:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import asyncio\nimport json\nimport aiohttp\nimport redis.asyncio as aioredis\n\nPROVIDER_URL = &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend&quot;\nAPI_KEY = &quot;pw_live_xxx&quot;\nCONCURRENCY = 8\n\n\nasync def send_one(session: aiohttp.ClientSession, job: dict) -&gt; None:\n    async with session.post(\n        PROVIDER_URL,\n        headers={&quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;},\n        json=job,\n        timeout=aiohttp.ClientTimeout(total=15),\n    ) as resp:\n        resp.raise_for_status()\n\n\nasync def worker(name: int, redis_client, session) -&gt; None:\n    while True:\n        item = await redis_client.brpop(&quot;email:outbound&quot;, timeout=5)\n        if item is None:\n            continue\n        job = json.loads(item[1])\n        # wait_for_token() equivalent goes here before sending\n        try:\n            await send_one(session, job)\n        except Exception:\n            await redis_client.lpush(&quot;email:dead&quot;, json.dumps(job))\n\n\nasync def main() -&gt; None:\n    redis_client = aioredis.Redis(host=&quot;localhost&quot;, port=6379, db=0)\n    async with aiohttp.ClientSession() as session:\n        await asyncio.gather(\n            *(worker(i, redis_client, session) for i in range(CONCURRENCY))\n        )\n\n\nif __name__ == &quot;__main__&quot;:\n    asyncio.run(main())\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Async shines when calls are I\u002FO-bound (they are — you're waiting on HTTP). One async worker process with 8–16 concurrent tasks easily covers this scale.\u003C\u002Fp>\n\u003Ch2>Multiple Domains and IPs: Isolate and Protect Reputation\u003C\u002Fh2>\n\u003Cp>At a million emails a month, sender reputation becomes your most valuable — and most fragile — asset. The single most effective architectural decision is \u003Cstrong>stream isolation\u003C\u002Fstrong>: separate your mail by type onto different domains (and, at higher volume, different IPs).\u003C\u002Fp>\n\u003Ch3>Why separate streams\u003C\u002Fh3>\n\u003Cp>Different email types have wildly different engagement and risk profiles:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional\u003C\u002Fstrong> (password resets, receipts, 2FA codes) — high engagement, low complaint rate, business-critical.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Notifications\u003C\u002Fstrong> (digests, alerts) — medium engagement.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Marketing \u002F lifecycle\u003C\u002Fstrong> (newsletters, re-engagement) — higher complaint and unsubscribe rates.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>If you send all three from one domain, a marketing campaign that triggers spam complaints will drag down the reputation of your password-reset emails — and now users can't log in. \u003Cstrong>Isolating streams keeps a problem in one lane from contaminating the others.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>A typical setup:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Stream\u003C\u002Fth>\n\u003Cth>Subdomain\u003C\u002Fth>\n\u003Cth>Why\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Transactional\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>mail.yourapp.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Protect critical delivery\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Notifications\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>notify.yourapp.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Medium-risk, separate reputation\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Marketing\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>news.yourapp.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Quarantine complaint risk\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Each subdomain gets its own SPF, DKIM, and DMARC records, so reputation accrues independently.\u003C\u002Fp>\n\u003Ch3>Dedicated IPs and warm-up\u003C\u002Fh3>\n\u003Cp>Below roughly 100,000 emails a month, a \u003Cstrong>shared IP pool\u003C\u002Fstrong> (managed by your provider) is usually better — your volume is too low to build IP reputation on a dedicated IP, and a quiet dedicated IP looks suspicious to ISPs.\u003C\u002Fp>\n\u003Cp>At a million a month and growing, a \u003Cstrong>dedicated IP\u003C\u002Fstrong> becomes viable and gives you control. But a new IP has zero reputation and must be \u003Cem>warmed up\u003C\u002Fem> — sending volume must ramp gradually so ISPs learn to trust it:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Day\u003C\u002Fth>\n\u003Cth>Daily volume (example)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1\u003C\u002Ftd>\n\u003Ctd>50\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>2\u003C\u002Ftd>\n\u003Ctd>100\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3\u003C\u002Ftd>\n\u003Ctd>500\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>7\u003C\u002Ftd>\n\u003Ctd>5,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>14\u003C\u002Ftd>\n\u003Ctd>25,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>30+\u003C\u002Ftd>\n\u003Ctd>Full volume\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Blasting a cold IP with 33,000 emails on day one guarantees deferrals and blocks. Most teams should let their provider manage warm-up and IP pooling rather than doing it by hand.\u003C\u002Fp>\n\u003Ch2>Retries: Fail Gracefully, Never Lose a Message\u003C\u002Fh2>\n\u003Cp>Transient failures are normal at scale — timeouts, \u003Ccode>429\u003C\u002Fcode>s, brief provider blips, ISP greylisting. The rule: \u003Cstrong>retry transient failures with exponential backoff; never retry permanent ones; never lose a message.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3>Classify before you retry\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Failure type\u003C\u002Fth>\n\u003Cth>Examples\u003C\u002Fth>\n\u003Cth>Action\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Transient\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>429\u003C\u002Fcode>, \u003Ccode>5xx\u003C\u002Fcode>, timeout, connection reset\u003C\u002Ftd>\n\u003Ctd>Retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Soft bounce\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Mailbox full, greylisting (\u003Ccode>4xx\u003C\u002Fcode> SMTP)\u003C\u002Ftd>\n\u003Ctd>Retry later (provider usually handles)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Permanent\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Invalid recipient, \u003Ccode>5xx\u003C\u002Fcode> SMTP, hard bounce\u003C\u002Ftd>\n\u003Ctd>Do \u003Cstrong>not\u003C\u002Fstrong> retry; suppress address\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Bad request\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>400\u003C\u002Fcode>, malformed payload\u003C\u002Ftd>\n\u003Ctd>Do \u003Cstrong>not\u003C\u002Fstrong> retry; alert (it's a bug)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Retrying a hard bounce is one of the fastest ways to destroy your reputation — repeatedly hammering a dead address is exactly what ISPs penalize.\u003C\u002Fp>\n\u003Ch3>Exponential backoff with jitter\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-python\">import random\nimport time\n\n\ndef retry_delays(max_retries: int = 6, base: float = 2.0, cap: float = 300.0):\n    &quot;&quot;&quot;Yield backoff delays: 2s, 4s, 8s ... capped, with jitter.&quot;&quot;&quot;\n    for attempt in range(max_retries):\n        delay = min(cap, base * (2 ** attempt))\n        # Full jitter avoids thundering-herd retries after an outage.\n        yield random.uniform(0, delay)\n\n\ndef process_with_retries(job: dict) -&gt; bool:\n    for delay in retry_delays():\n        try:\n            resp = send_via_provider(job)\n            if resp.status_code in (429,) or resp.status_code &gt;= 500:\n                time.sleep(delay)\n                continue\n            resp.raise_for_status()\n            return True\n        except requests.RequestException:\n            time.sleep(delay)\n    return False  # exhausted -&gt; send to dead-letter queue\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Jitter matters: after a provider outage recovers, you don't want every worker retrying in lockstep and re-creating the outage.\u003C\u002Fp>\n\u003Ch3>The dead-letter queue\u003C\u002Fh3>\n\u003Cp>When retries are exhausted, move the message to a DLQ rather than dropping it. The DLQ is your safety net and audit trail — you can inspect why messages failed, fix a bug, and replay them. A queue with no DLQ silently loses mail under load, which is the worst possible failure for transactional email.\u003C\u002Fp>\n\u003Ch2>Monitoring: See Problems Before Users Do\u003C\u002Fh2>\n\u003Cp>You cannot scale email infrastructure you can't observe. At a million a month, a 2% delivery drop is 20,000 failed emails — but it's invisible unless you measure it. Provider acceptance (\u003Ccode>250 OK\u003C\u002Fcode> \u002F \u003Ccode>200\u003C\u002Fcode>) tells you nothing about whether the message reached the inbox.\u003C\u002Fp>\n\u003Ch3>Metrics to track\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Metric\u003C\u002Fth>\n\u003Cth>Formula\u003C\u002Fth>\n\u003Cth>Healthy target\u003C\u002Fth>\n\u003Cth>Alert threshold\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Delivery rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>delivered ÷ sent\u003C\u002Ftd>\n\u003Ctd>&gt; 98%\u003C\u002Ftd>\n\u003Ctd>&lt; 95%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Hard bounce rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>hard bounces ÷ sent\u003C\u002Ftd>\n\u003Ctd>&lt; 0.5%\u003C\u002Ftd>\n\u003Ctd>&gt; 2%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Complaint rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>complaints ÷ delivered\u003C\u002Ftd>\n\u003Ctd>&lt; 0.1%\u003C\u002Ftd>\n\u003Ctd>&gt; 0.3%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Queue depth\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>pending messages\u003C\u002Ftd>\n\u003Ctd>near 0 baseline\u003C\u002Ftd>\n\u003Ctd>sustained growth\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Send latency\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>enqueue → delivered\u003C\u002Ftd>\n\u003Ctd>seconds–minutes\u003C\u002Ftd>\n\u003Ctd>minutes growing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Retry\u002FDLQ rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>retried or dead ÷ sent\u003C\u002Ftd>\n\u003Ctd>low\u003C\u002Ftd>\n\u003Ctd>spiking\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Track every one of these \u003Cstrong>per sending domain and per email type\u003C\u002Fstrong>. An aggregate 98% delivery rate can hide a \u003Ccode>password_reset\u003C\u002Fcode> stream at 80% because of one broken DKIM record. Segmentation is what makes monitoring actionable.\u003C\u002Fp>\n\u003Ch3>Capture events with webhooks\u003C\u002Fh3>\n\u003Cp>Delivery, bounce, and complaint events arrive asynchronously via webhooks. Acknowledge fast, process async, and feed both metrics and the suppression list:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">from flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n\n@app.post(&quot;\u002Fwebhooks\u002Femail&quot;)\ndef email_events():\n    event = request.get_json(force=True)\n    event_type = event.get(&quot;type&quot;)\n    address = event.get(&quot;recipient&quot;)\n\n    if event_type in (&quot;bounced&quot;, &quot;complained&quot;):\n        add_to_suppression_list(address)  # never email this address again\n\n    record_metric(event_type, stream=event.get(&quot;stream&quot;))\n    return jsonify({&quot;ok&quot;: True}), 200\n\n\ndef add_to_suppression_list(address: str) -&gt; None:\n    ...  # write to your suppression store\n\n\ndef record_metric(event_type: str, stream: str) -&gt; None:\n    ...  # increment counters in your metrics backend\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Wire threshold alerts (delivery-rate cliff, complaint spike) \u003Cstrong>and\u003C\u002Fstrong> a zero-volume alarm so a silent pipeline failure doesn't masquerade as health.\u003C\u002Fp>\n\u003Ch2>Cost at Scale: What a Million Emails Really Costs\u003C\u002Fh2>\n\u003Cp>Cost has two components: \u003Cstrong>the email provider\u003C\u002Fstrong> and \u003Cstrong>your own infrastructure.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3>Provider cost\u003C\u002Fh3>\n\u003Cp>Transactional providers price per thousand emails or via monthly tiers. At a million a month, expect a meaningful but not enormous bill:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Pricing model\u003C\u002Fth>\n\u003Cth>Typical range (1M\u002Fmonth)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Per-email metered\u003C\u002Ftd>\n\u003Ctd>~$0.10–$1.00 per 1,000 → \u003Cstrong>$100–$1,000\u002Fmo\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Flat monthly tier\u003C\u002Ftd>\n\u003Ctd>Often \u003Cstrong>$80–$400\u002Fmo\u003C\u002Fstrong> for ~1M\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Self-hosted SMTP (own MTA)\u003C\u002Ftd>\n\u003Ctd>\"Cheaper\" per-email, high engineering + deliverability cost\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Self-hosting your own mail transfer agent (Postfix\u002FHaraka on bare metal) looks cheapest on paper, but you then own IP warm-up, blocklist remediation, feedback loops, and on-call deliverability — months of engineering that adds no product value. For almost every SaaS, \u003Cstrong>a managed provider is cheaper once you price in engineering time.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3>Your infrastructure cost\u003C\u002Fh3>\n\u003Cp>The queue + workers footprint for this scale is small:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Redis\u003C\u002Fstrong>: a small managed instance (~$15–$50\u002Fmo).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Workers\u003C\u002Fstrong>: a couple of small containers, autoscaling on burst (~$20–$80\u002Fmo).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Webhook consumer + metrics\u003C\u002Fstrong>: minimal.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Total self-run infrastructure for the \u003Cem>plumbing\u003C\u002Fem> is often under $150\u002Fmonth — the queue and workers are cheap; the value (and most of the cost) is in deliverability and reputation, which is exactly what a good provider handles for you.\u003C\u002Fp>\n\u003Ch3>Cost optimization levers\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Suppress aggressively\u003C\u002Fstrong> — every email to a dead address is wasted spend \u003Cem>and\u003C\u002Fem> reputation damage.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Validate at capture\u003C\u002Fstrong> — reject malformed addresses before they enter the queue.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Batch where the provider supports it\u003C\u002Fstrong> — fewer API calls, lower overhead.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Right-size workers\u003C\u002Fstrong> — autoscale down when the queue is empty; don't pay for idle concurrency.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Common Mistakes When You Send Millions of Emails\u003C\u002Fh2>\n\u003Ch3>1. Sending synchronously from request handlers\u003C\u002Fh3>\n\u003Cp>Calling the email API inside a user request couples your app's responsiveness to the provider's. A blip becomes a user-facing timeout, and a crash loses the message. Always enqueue, never send inline.\u003C\u002Fp>\n\u003Ch3>2. No global rate limiter across workers\u003C\u002Fh3>\n\u003Cp>Adding workers without a \u003Cem>shared\u003C\u002Fem> rate limiter multiplies your send rate and triggers provider \u003Ccode>429\u003C\u002Fcode>s and ISP throttling. The rate limit must be global, not per-worker.\u003C\u002Fp>\n\u003Ch3>3. Treating \"accepted\" as \"delivered\"\u003C\u002Fh3>\n\u003Cp>A \u003Ccode>200\u003C\u002Fcode>\u002F\u003Ccode>250 OK\u003C\u002Fcode> means the provider queued the message, not that it reached the inbox. Without webhook-based delivery tracking you're blind to the half of the lifecycle that actually matters.\u003C\u002Fp>\n\u003Ch3>4. Mixing all email types on one domain\u003C\u002Fh3>\n\u003Cp>One spammy marketing run can tank the reputation that your password-reset emails depend on. Isolate transactional, notification, and marketing streams onto separate subdomains.\u003C\u002Fp>\n\u003Ch3>5. Retrying permanent failures\u003C\u002Fh3>\n\u003Cp>Re-sending to hard-bounced or complained addresses is the fastest route to a blocklist. Classify failures and suppress permanent ones immediately.\u003C\u002Fp>\n\u003Ch3>6. No dead-letter queue\u003C\u002Fh3>\n\u003Cp>A pipeline that drops messages when retries are exhausted loses transactional mail silently under exactly the load conditions where it matters most. Always capture exhausted jobs in a DLQ.\u003C\u002Fp>\n\u003Ch3>7. Cold-IP blasting \u002F skipping warm-up\u003C\u002Fh3>\n\u003Cp>Pointing full volume at a brand-new dedicated IP guarantees deferrals and blocks. Warm up gradually, or let your provider manage IP pooling.\u003C\u002Fp>\n\u003Ch3>8. Aggregating metrics into one number\u003C\u002Fh3>\n\u003Cp>A single global delivery rate hides per-stream failures. Always segment monitoring by domain and email type.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>How do I send millions of emails per month without getting blocked?\u003C\u002Fh3>\n\u003Cp>Decouple acceptance from delivery with a durable queue, throttle sends with a shared rate limiter that respects both your provider and ISP limits, isolate email types onto separate authenticated subdomains, suppress bounces and complaints automatically, and warm up any new IP gradually. Getting blocked at this scale is almost always a reputation problem caused by sending too fast from a cold sender or repeatedly mailing bad addresses — not a raw-volume problem. A million a month is a small workload for properly designed scalable email infrastructure.\u003C\u002Fp>\n\u003Ch3>What is the best architecture for scalable email infrastructure?\u003C\u002Fh3>\n\u003Cp>The proven pattern is: application → durable queue → rate-limited worker pool → email provider → async webhook event consumer → metrics, suppression, and alerting. The queue absorbs bursts and guarantees durability; the worker pool provides horizontal concurrency; a shared rate limiter caps the send rate to what your reputation can sustain; and webhooks give you delivery visibility. This decouples your app's responsiveness from delivery and lets each layer scale independently.\u003C\u002Fp>\n\u003Ch3>How many workers do I need to send 1 million emails a month?\u003C\u002Fh3>\n\u003Cp>Far fewer than people expect. One million a month averages under one email per second, so even at a peak of a few sends per second with ~200 ms provider latency, you need only a handful of concurrent in-flight requests — roughly 4–8 workers, or a single async worker process. You provision extra workers not for the average rate but to drain bursts quickly; a shared rate limiter keeps the per-second send rate safe no matter how many workers you run.\u003C\u002Fp>\n\u003Ch3>Do I need a dedicated IP to send millions of emails?\u003C\u002Fh3>\n\u003Cp>Not at one million a month. Below roughly 100,000 a month a shared IP pool managed by your provider is usually better, because your volume is too low to build reputation on a dedicated IP — and a quiet dedicated IP looks suspicious to ISPs. Around a million a month and climbing, a dedicated IP becomes viable and gives you more control, but it must be warmed up gradually. For most teams, letting the provider manage IP pooling and warm-up is the better trade-off.\u003C\u002Fp>\n\u003Ch3>How should I handle retries when sending email at scale?\u003C\u002Fh3>\n\u003Cp>Classify failures first. Retry transient errors (\u003Ccode>429\u003C\u002Fcode>, \u003Ccode>5xx\u003C\u002Fcode>, timeouts) with exponential backoff plus jitter; never retry permanent failures (invalid recipients, hard bounces) because re-mailing dead addresses destroys your reputation; and when retries are exhausted, move the message to a dead-letter queue rather than dropping it. Jitter prevents every worker from retrying in lockstep after an outage and re-creating it.\u003C\u002Fp>\n\u003Ch3>What does it cost to send a million emails a month?\u003C\u002Fh3>\n\u003Cp>Provider cost typically lands between roughly $100 and $1,000 per month depending on pricing model, with many flat tiers around $80–$400. Your own plumbing — a small Redis instance plus a couple of autoscaling worker containers — is often under $150 a month. Self-hosting your own MTA looks cheaper per email but adds months of deliverability engineering and ongoing on-call, so a managed provider is usually cheaper once you price in engineering time.\u003C\u002Fp>\n\u003Ch3>How do I monitor email delivery at scale?\u003C\u002Fh3>\n\u003Cp>Ingest delivery, bounce, and complaint webhooks from your provider, then compute delivery rate, hard bounce rate, complaint rate, queue depth, send latency, and DLQ rate — segmented per sending domain and per email type. Set threshold alerts (a delivery-rate cliff, a complaint spike) and a zero-volume alarm so a silent pipeline failure doesn't pass for health. Aggregate-only metrics hide per-stream failures, which are the ones that actually break user flows.\u003C\u002Fp>\n\u003Ch3>Why use a queue instead of sending email directly from my app?\u003C\u002Fh3>\n\u003Cp>A queue decouples accepting an email from delivering it. Your app enqueues in microseconds and returns, so provider slowness never becomes user-facing slowness; messages persist through crashes and deploys instead of vanishing from memory; bursts are absorbed and drained at a safe rate; and your web tier and delivery tier scale independently. Sending directly from request handlers couples your application's reliability to your email provider's — the opposite of what scalable email infrastructure should do.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Scaling to \u003Cstrong>send millions of emails\u003C\u002Fstrong> a month is an exercise in decoupling and discipline, not raw horsepower. A million a month is a modest rate — under one email per second on average — but real traffic is bursty and sender reputation is fragile, so the architecture has to absorb spikes without losing messages or overrunning ISPs.\u003C\u002Fp>\n\u003Cp>The blueprint is consistent: a durable queue to buffer and protect against loss, a shared rate limiter that respects both provider and ISP limits, a horizontally scaled worker pool for concurrency, stream isolation across authenticated domains to protect reputation, exponential-backoff retries with a dead-letter queue so nothing is silently dropped, and per-stream monitoring with alerting so you see trouble before your users do. Each piece is implementable incrementally — start with the queue, then add rate limiting, then monitoring.\u003C\u002Fp>\n\u003Cp>Build it this way and a million emails a month is routine, and the same design carries you to ten million with little more than more workers and a dedicated IP. The teams that struggle are the ones still sending synchronously from request handlers and discovering reputation problems only after delivery has already collapsed.\u003C\u002Fp>\n\u003Ch2>Send and Scale Transactional Email with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers and SaaS companies that need \u003Cstrong>scalable email infrastructure\u003C\u002Fstrong> without building the deliverability stack themselves. You get a fast send API designed to sit behind your queue, generous rate limits, automatic suppression of bounces and complaints, managed IP pooling and warm-up, and webhook events for every lifecycle stage — so the queueing, rate limiting, retries, and monitoring patterns in this guide plug straight in.\u003C\u002Fp>\n\u003Cp>SPF, DKIM, and DMARC setup, multiple sending domains for stream isolation, and per-domain delivery dashboards come built in, so you can isolate transactional from marketing reputation without operating your own MTAs. And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, international founders and crypto-native teams can scale up without card requirements or traditional payment friction.\u003C\u002Fp>\n\u003Cp>Stop wiring deliverability together by hand. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending millions of emails with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","0ff7c856-51c6-4430-9a20-91eb18ae4981","2026-06-24T18:28:18.074703+03:00","2026-07-15T23:56:49.381176+03:00","How to Scale to 1 Million Emails per Month","Learn how to send millions of emails a month: queueing, rate limits, concurrency, multiple domains, retries, and monitoring for scalable email infrastructure.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_0ff7c856-51c6-4430-9a20-91eb18ae4981\u002Fscale-to-one-million-emails.png","2026-08-05T09:00:00+03:00",[47,48],"send millions of emails","scalable email infrastructure",{"id":50,"body":51,"uuid":52,"created_at":53,"updated_at":54,"brand":13,"header":55,"short_body":56,"image":57,"published":17,"published_at":58,"tags":59},20,"\u003Cp>Choosing between a \u003Cstrong>dedicated IP email\u003C\u002Fstrong> setup and a \u003Cstrong>shared IP email\u003C\u002Fstrong> pool is one of the most consequential deliverability decisions a SaaS team makes — and one of the easiest to get wrong. Pick a dedicated IP too early and your low volume starves the IP of the reputation it needs to land in the inbox. Stay on a shared pool too long and you inherit the sending habits of strangers. This guide breaks down exactly when a dedicated IP email strategy pays off, when a shared IP email pool is the smarter call, and how volume, warm-up, reputation, and cost factor into the choice.\u003C\u002Fp>\n\u003Cp>Whether you are sending password resets for 500 users or a million receipts a day, the right answer depends on hard numbers — not vendor marketing. Let's get into them.\u003C\u002Fp>\n\u003Ch2>What Is a Dedicated IP vs a Shared IP?\u003C\u002Fh2>\n\u003Cp>A sending IP address is the network identity that mailbox providers like Gmail, Outlook, and Yahoo use to evaluate the trustworthiness of your email. Reputation is attached to that IP (and increasingly to your sending domain). The two models differ in who else uses that address.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Dedicated IP email:\u003C\u002Fstrong> Your application is the only sender on a specific IP address. Every reputation signal — bounces, spam complaints, engagement, volume consistency — is generated by your traffic alone. You own the reputation, good or bad.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Shared IP email:\u003C\u002Fstrong> Your messages go out from a pool of IP addresses used by many customers of the same email service provider (ESP). Reputation is aggregated across all senders in the pool. The provider actively manages and balances the pool to keep it healthy.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Think of it as the difference between owning a house and renting an apartment in a well-managed building. On a dedicated IP, you are responsible for maintenance and warm-up, but nobody else can damage your standing. On a shared IP, the provider handles maintenance and your neighbors' behavior is curated, but you don't fully control the building's reputation.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Quick answer for AI Overviews and featured snippets:\u003C\u002Fstrong> Use a \u003Cstrong>shared IP\u003C\u002Fstrong> when you send fewer than ~50,000–100,000 emails per day and want zero warm-up overhead. Use a \u003Cstrong>dedicated IP\u003C\u002Fstrong> when you send consistently high volume (often 100,000+\u002Fday), need full control over your sender reputation, or must isolate transactional mail from marketing mail.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>How Sender Reputation Actually Works\u003C\u002Fh2>\n\u003Cp>Before comparing the two models, you need to understand what reputation is built from, because it drives the entire decision.\u003C\u002Fp>\n\u003Cp>Mailbox providers score each sending IP and domain on signals including:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Spam complaint rate\u003C\u002Fstrong> — recipients clicking \"report spam.\" Anything above ~0.1% (1 in 1,000) is a red flag; 0.3% triggers serious throttling.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bounce rate\u003C\u002Fstrong> — messages to invalid addresses. High hard-bounce rates signal poor list hygiene.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Spam-trap hits\u003C\u002Fstrong> — sending to addresses that exist only to catch poor senders.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Engagement\u003C\u002Fstrong> — opens, replies, and especially the absence of deletes-without-reading.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Volume consistency\u003C\u002Fstrong> — sudden spikes look like compromised accounts or spam campaigns.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authentication\u003C\u002Fstrong> — valid SPF, DKIM, and a DMARC policy.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>On a \u003Cstrong>dedicated IP email\u003C\u002Fstrong> address, every one of these signals is 100% yours. That is powerful if you send clean, engaging mail — and dangerous if you don't. On a \u003Cstrong>shared IP email\u003C\u002Fstrong> pool, your signals are blended with everyone else's, so a single bad day for you barely moves the pool, but a bad pool can drag down a perfectly good sender.\u003C\u002Fp>\n\u003Ch2>When a Shared IP Email Pool Makes Sense\u003C\u002Fh2>\n\u003Cp>A shared IP is the correct default for the vast majority of SaaS and developer use cases. Here is when to stay on one.\u003C\u002Fp>\n\u003Ch3>Low or Inconsistent Volume\u003C\u002Fh3>\n\u003Cp>This is the single biggest factor. Mailbox providers want to see \u003Cstrong>consistent, predictable volume\u003C\u002Fstrong> from a dedicated IP. If you send 2,000 emails on Monday, 200 on Tuesday, and nothing on Wednesday, a dedicated IP never accumulates enough signal to build trust — and idle IPs actually \u003Cem>decay\u003C\u002Fem> in reputation.\u003C\u002Fp>\n\u003Cp>As a rule of thumb, a dedicated IP needs roughly \u003Cstrong>50,000 or more emails per day, sent consistently\u003C\u002Fstrong>, to stay warm. Below that, the reputation signal is too thin and a well-managed shared pool will outperform you.\u003C\u002Fp>\n\u003Ch3>You Want Zero Warm-Up Overhead\u003C\u002Fh3>\n\u003Cp>A shared pool is already warm. You can start sending at full volume on day one because the pool has established reputation. There is no ramp schedule to babysit, no risk of tripping rate limits during a launch.\u003C\u002Fp>\n\u003Ch3>You Send Mostly Transactional Email\u003C\u002Fh3>\n\u003Cp>Transactional email — password resets, receipts, 2FA codes, account notifications — tends to have excellent engagement (people \u003Cem>want\u003C\u002Fem> these messages) and very low complaint rates. This is exactly the traffic that keeps a shared pool healthy, and the pool's curated reputation rewards you with strong inbox placement immediately.\u003C\u002Fp>\n\u003Ch3>You Don't Have Deliverability Engineering Resources\u003C\u002Fh3>\n\u003Cp>Running a dedicated IP well requires monitoring blocklists, watching per-mailbox-provider placement, managing warm-up, and reacting to reputation dips. If nobody on your team owns email deliverability, a managed shared pool quietly handles this for you.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Shared IP is the right call when:\u003C\u002Fstrong> your daily volume is under ~50,000, your sending is bursty or seasonal, your traffic is primarily transactional, and you'd rather ship product than tune IP reputation.\u003C\u002Fp>\n\u003Ch2>When a Dedicated IP Email Setup Makes Sense\u003C\u002Fh2>\n\u003Cp>A dedicated IP email address becomes the better choice once you cross certain thresholds and need control. Here's when to make the switch.\u003C\u002Fp>\n\u003Ch3>Consistent High Volume\u003C\u002Fh3>\n\u003Cp>The clearest signal. Once you are reliably sending \u003Cstrong>100,000+ emails per day\u003C\u002Fstrong> with a steady daily pattern, you have enough volume to build and maintain strong reputation on your own IP. At that scale, the upside of full control outweighs the warm-up cost.\u003C\u002Fp>\n\u003Ch3>You Need Full Control Over Reputation\u003C\u002Fh3>\n\u003Cp>On a dedicated IP, no other sender can damage your standing. If a single noisy neighbor in a shared pool gets a domain blocklisted, you're insulated. For companies where email \u003Cem>is\u003C\u002Fem> the product (notifications platforms, communication tools, large marketplaces), owning reputation end-to-end is worth the operational cost.\u003C\u002Fp>\n\u003Ch3>Regulatory, Brand, or Isolation Requirements\u003C\u002Fh3>\n\u003Cp>Some businesses must isolate sending streams — for example, separating high-stakes transactional mail (legal notices, financial confirmations) from marketing campaigns so a marketing complaint spike can never delay a password reset. Dedicated IPs let you partition reputation by stream.\u003C\u002Fp>\n\u003Ch3>Predictable, Diagnosable Deliverability\u003C\u002Fh3>\n\u003Cp>When something goes wrong on a dedicated IP, the cause is yours and the fix is in your control. On a shared pool, you are partly at the mercy of the provider's pool management. Mature senders often prefer the predictability of \"what I send is what I'm judged on.\"\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Dedicated IP is the right call when:\u003C\u002Fstrong> you send 100,000+\u002Fday consistently, email is mission-critical to your product, you need to isolate sending streams, and you have (or are willing to build) deliverability monitoring.\u003C\u002Fp>\n\u003Ch2>Volume Thresholds: The Numbers That Decide\u003C\u002Fh2>\n\u003Cp>Volume is the deciding variable. Here is a practical breakdown.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Daily volume\u003C\u002Fth>\n\u003Cth>Recommendation\u003C\u002Fth>\n\u003Cth>Why\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Under 10,000\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Shared IP\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Far too little signal to warm or sustain a dedicated IP.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>10,000–50,000\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Shared IP\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Still better on a curated pool unless volume is rock-steady.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>50,000–100,000\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Either\u003C\u002Fstrong> — depends on consistency\u003C\u002Ftd>\n\u003Ctd>Dedicated IP becomes viable \u003Cem>if\u003C\u002Fem> volume is daily and steady.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>100,000–1,000,000\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Dedicated IP\u003C\u002Fstrong> (often multiple)\u003C\u002Ftd>\n\u003Ctd>Enough volume to own reputation; isolate streams across IPs.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>1,000,000+\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Multiple dedicated IPs\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Spread load, segment by traffic type, and add redundancy.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>A useful gut check: if you can't send \u003Cstrong>at least ~5,000 emails every single day\u003C\u002Fstrong> on a dedicated IP, it will struggle to stay warm. Many providers cite a minimum of around 50,000\u002Fday for a single dedicated IP to thrive.\u003C\u002Fp>\n\u003Ch2>IP Warm-Up: The Hidden Cost of Going Dedicated\u003C\u002Fh2>\n\u003Cp>The biggest operational difference between the two models is warm-up. A brand-new dedicated IP has \u003Cstrong>no reputation at all\u003C\u002Fstrong> — and to mailbox providers, \"unknown\" is treated with suspicion. Blast your full volume from a cold IP and you'll get throttled, deferred, or junked.\u003C\u002Fp>\n\u003Cp>Warm-up means gradually increasing volume over \u003Cstrong>two to six weeks\u003C\u002Fstrong> so providers learn your IP sends legitimate, wanted mail. Skipping it is the most common — and most damaging — dedicated IP mistake.\u003C\u002Fp>\n\u003Ch3>A Sample Warm-Up Schedule\u003C\u002Fh3>\n\u003Cp>A typical ramp doubles volume roughly every couple of days while watching bounce and complaint rates. A conservative schedule looks like this:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Day\u003C\u002Fth>\n\u003Cth>Max volume\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1\u003C\u002Ftd>\n\u003Ctd>50\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>2\u003C\u002Ftd>\n\u003Ctd>100\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3\u003C\u002Ftd>\n\u003Ctd>500\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>4\u003C\u002Ftd>\n\u003Ctd>1,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>5\u003C\u002Ftd>\n\u003Ctd>5,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>7\u003C\u002Ftd>\n\u003Ctd>10,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>10\u003C\u002Ftd>\n\u003Ctd>25,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>14\u003C\u002Ftd>\n\u003Ctd>75,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>21\u003C\u002Ftd>\n\u003Ctd>150,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>30\u003C\u002Ftd>\n\u003Ctd>Full volume\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>During warm-up, send to your \u003Cstrong>most engaged recipients first\u003C\u002Fstrong>. Their opens and clicks generate the positive signal that builds reputation fastest. Save unengaged or risky segments for after the IP is established.\u003C\u002Fp>\n\u003Ch3>Automating the Warm-Up Throttle\u003C\u002Fh3>\n\u003Cp>If you control your own sending, you can cap daily volume programmatically during the ramp. Here's a minimal example in Python that enforces a warm-up ceiling before handing a batch to your email API:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">from datetime import date\n\n# Warm-up ceiling by day number since the IP went live\nWARMUP_SCHEDULE = {\n    1: 50, 2: 100, 3: 500, 4: 1000, 5: 5000,\n    7: 10000, 10: 25000, 14: 75000, 21: 150000,\n}\n\ndef daily_cap(day_number: int) -&gt; int | None:\n    &quot;&quot;&quot;Return today's send ceiling, or None once fully warmed (day &gt;= 30).&quot;&quot;&quot;\n    if day_number &gt;= 30:\n        return None  # no cap; IP is warm\n    # Use the most recent scheduled cap at or before today\n    applicable = [cap for day, cap in WARMUP_SCHEDULE.items() if day &lt;= day_number]\n    return max(applicable) if applicable else 50\n\ndef can_send(day_number: int, sent_today: int) -&gt; bool:\n    cap = daily_cap(day_number)\n    return cap is None or sent_today &lt; cap\n\n# Example usage\ngo_live = date(2026, 6, 1)\nday = (date.today() - go_live).days + 1\nif can_send(day, sent_today=4200):\n    send_next_batch()\nelse:\n    queue_for_tomorrow()\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>With a managed platform like Postwing, you can lean on a shared pool while you build volume and switch to a dedicated IP — with guided warm-up — only once your numbers justify it, so you don't hand-roll throttling logic before you need it.\u003C\u002Fp>\n\u003Ch2>Cost Comparison: Dedicated IP vs Shared IP\u003C\u002Fh2>\n\u003Cp>Cost is rarely the deciding factor, but it matters at the margins.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Factor\u003C\u002Fth>\n\u003Cth>Shared IP email\u003C\u002Fth>\n\u003Cth>Dedicated IP email\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Monthly IP fee\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Usually included\u003C\u002Ftd>\n\u003Ctd>Typically a flat add-on per IP per month\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Warm-up effort\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>None\u003C\u002Ftd>\n\u003Ctd>2–6 weeks of ramping and monitoring\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Engineering time\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Minimal\u003C\u002Ftd>\n\u003Ctd>Ongoing reputation\u002Fblocklist monitoring\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Reputation risk\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Shared with pool neighbors\u003C\u002Ftd>\n\u003Ctd>Fully isolated to your traffic\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Scaling cost\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Flat\u003C\u002Ftd>\n\u003Ctd>Linear — more volume may need more IPs\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Best for\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Low\u002Fmedium, bursty volume\u003C\u002Ftd>\n\u003Ctd>High, consistent volume\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The dollar cost of a dedicated IP is small. The \u003Cem>real\u003C\u002Fem> cost is the engineering attention required to warm it, monitor it, and keep it healthy. If you adopt a dedicated IP without budgeting that time, you've bought a liability, not an asset.\u003C\u002Fp>\n\u003Ch2>Full Comparison Table: Dedicated IP vs Shared IP\u003C\u002Fh2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Criterion\u003C\u002Fth>\n\u003Cth>Shared IP email\u003C\u002Fth>\n\u003Cth>Dedicated IP email\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Reputation ownership\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Shared across pool\u003C\u002Ftd>\n\u003Ctd>100% yours\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Warm-up required\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>No (pool is pre-warmed)\u003C\u002Ftd>\n\u003Ctd>Yes (2–6 weeks)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Ideal daily volume\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Under ~50,000\u003C\u002Ftd>\n\u003Ctd>100,000+ consistently\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Volume consistency needed\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Low — bursts are fine\u003C\u002Ftd>\n\u003Ctd>High — steady daily sending\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Setup speed\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Immediate\u003C\u002Ftd>\n\u003Ctd>Weeks (warm-up)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Control over deliverability\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Limited\u003C\u002Ftd>\n\u003Ctd>Full\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Risk from other senders\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Yes (noisy neighbors)\u003C\u002Ftd>\n\u003Ctd>None\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Operational overhead\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Low (provider-managed)\u003C\u002Ftd>\n\u003Ctd>High (you monitor)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Stream isolation\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Not possible\u003C\u002Ftd>\n\u003Ctd>Yes (per-IP)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Cost\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Often included\u003C\u002Ftd>\n\u003Ctd>Flat fee + engineering time\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Best for\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Most SaaS, transactional, startups\u003C\u002Ftd>\n\u003Ctd>High-volume, email-critical platforms\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch2>Practical Scenarios\u003C\u002Fh2>\n\u003Ch3>Scenario 1: Early-Stage SaaS, 3,000 Emails\u002FDay\u003C\u002Fh3>\n\u003Cp>A startup sending signup confirmations, password resets, and a weekly digest. \u003Cstrong>Verdict: shared IP.\u003C\u002Fstrong> Volume is far too low for a dedicated IP to stay warm, the traffic is high-engagement transactional mail, and there's no deliverability engineer on staff. A managed shared pool delivers excellent inbox placement with zero overhead.\u003C\u002Fp>\n\u003Ch3>Scenario 2: Growth-Stage SaaS, 80,000 Emails\u002FDay\u003C\u002Fh3>\n\u003Cp>A product sending real-time notifications plus marketing campaigns. \u003Cstrong>Verdict: it depends on consistency.\u003C\u002Fstrong> If those 80,000 emails go out every day on a steady curve, a dedicated IP is viable and offers reputation isolation. If volume swings between 20,000 and 200,000 depending on the week, stay on a shared pool a while longer — inconsistency will starve a dedicated IP.\u003C\u002Fp>\n\u003Ch3>Scenario 3: Notifications Platform, 2,000,000 Emails\u002FDay\u003C\u002Fh3>\n\u003Cp>A platform whose entire business is delivering email. \u003Cstrong>Verdict: multiple dedicated IPs.\u003C\u002Fstrong> Segment transactional from marketing, spread volume across IPs for redundancy, and own every reputation signal. At this scale, control is non-negotiable.\u003C\u002Fp>\n\u003Ch2>Common Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even experienced teams trip over these.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Going dedicated too early.\u003C\u002Fstrong> The number one mistake. Without enough consistent volume, a dedicated IP underperforms a shared pool. Don't switch for prestige — switch when the numbers demand it.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping or rushing warm-up.\u003C\u002Fstrong> Blasting full volume from a cold IP gets you throttled and can tank the new IP's reputation before it ever establishes one. Always ramp.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Letting a dedicated IP go idle.\u003C\u002Fstrong> Reputation decays without consistent volume. A dedicated IP that sits quiet for days is worse than no dedicated IP at all.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mixing transactional and marketing on the same IP.\u003C\u002Fstrong> A marketing complaint spike can delay your password resets. Isolate critical streams.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring authentication.\u003C\u002Fstrong> SPF, DKIM, and DMARC matter on \u003Cem>both\u003C\u002Fem> models. A dedicated IP with broken DKIM still lands in spam.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Confusing IP reputation with domain reputation.\u003C\u002Fstrong> Modern providers like Gmail weight \u003Cem>domain\u003C\u002Fem> reputation heavily. Switching IPs won't fix a poisoned domain. Protect both.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Choosing dedicated for \"control\" without resources.\u003C\u002Fstrong> Control means responsibility. If nobody monitors blocklists and placement, you don't have control — you have an unwatched liability.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>What is the difference between a dedicated IP and a shared IP for email?\u003C\u002Fh3>\n\u003Cp>A dedicated IP email address is used exclusively by your application, so you fully own its sender reputation. A shared IP email pool is used by many senders managed by one provider, with reputation aggregated and balanced across the group. Dedicated gives control; shared gives a pre-warmed, managed reputation with no setup effort.\u003C\u002Fp>\n\u003Ch3>When should I switch from a shared IP to a dedicated IP?\u003C\u002Fh3>\n\u003Cp>Switch when you consistently send roughly 100,000+ emails per day on a steady daily schedule, when you need to isolate sending streams (transactional vs marketing), or when full control over reputation becomes business-critical. Below ~50,000 emails\u002Fday, a shared IP almost always delivers better.\u003C\u002Fp>\n\u003Ch3>How long does dedicated IP warm-up take?\u003C\u002Fh3>\n\u003Cp>Typically two to six weeks. You start with a few dozen emails and roughly double volume every couple of days, sending to your most engaged recipients first, while watching bounce and complaint rates. Rushing this is the most common cause of new dedicated IPs landing in spam.\u003C\u002Fp>\n\u003Ch3>Is a dedicated IP always better for deliverability?\u003C\u002Fh3>\n\u003Cp>No. A dedicated IP is only better if you send enough consistent volume to keep it warm and you actively manage its reputation. For low-volume or bursty senders, a well-managed shared IP email pool delivers better inbox placement because it already has established reputation.\u003C\u002Fp>\n\u003Ch3>Does a dedicated IP guarantee inbox placement?\u003C\u002Fh3>\n\u003Cp>No. Inbox placement depends on engagement, complaint rates, list hygiene, authentication (SPF\u002FDKIM\u002FDMARC), and increasingly your \u003Cem>domain\u003C\u002Fem> reputation — not the IP alone. A dedicated IP with poor sending practices will still land in spam.\u003C\u002Fp>\n\u003Ch3>How many dedicated IPs do I need?\u003C\u002Fh3>\n\u003Cp>Most senders start with one. Past roughly 1,000,000 emails\u002Fday, add IPs to spread volume, segment by traffic type (transactional vs marketing), and build redundancy. Each additional IP needs its own warm-up.\u003C\u002Fp>\n\u003Ch3>Can I use both a shared and a dedicated IP at once?\u003C\u002Fh3>\n\u003Cp>Yes, and many high-volume senders do. A common pattern is routing transactional mail through a dedicated IP for control and routing lower-volume or experimental streams through a shared pool. This hybrid approach isolates your most important traffic while keeping flexibility.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>The dedicated IP vs shared IP decision comes down to one question: \u003Cstrong>do you have the consistent volume and operational capacity to own your reputation?\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Choose a \u003Cstrong>shared IP email\u003C\u002Fstrong> pool when your volume is under ~50,000\u002Fday, your sending is bursty, your traffic is mostly transactional, or you'd rather not run deliverability operations. The pool is pre-warmed and managed, so you land in the inbox from day one.\u003C\u002Fli>\n\u003Cli>Choose a \u003Cstrong>dedicated IP email\u003C\u002Fstrong> setup when you send 100,000+\u002Fday consistently, need stream isolation, or require full control — and you're ready to invest in warm-up and ongoing monitoring.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Most SaaS companies should start on a shared pool and graduate to a dedicated IP only when the numbers — and the need for control — clearly justify it. Premature dedication is a far more common and costly mistake than staying shared a little too long.\u003C\u002Fp>\n\u003Ch2>Send Transactional Email That Lands in the Inbox — With Postwing\u003C\u002Fh2>\n\u003Cp>Postwing is a transactional email delivery platform built for developers and SaaS teams. Start on a curated, pre-warmed shared pool for instant inbox placement, then move to a dedicated IP with guided warm-up exactly when your volume justifies it — no hand-rolled throttling, no guesswork. Built-in SPF, DKIM, and DMARC support and real-time deliverability monitoring keep your reputation healthy on either model.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can pay for email infrastructure on-chain — no card required, no friction for global teams.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing\u003C\u002Fa>\u003C\u002Fstrong> and get your transactional email into the inbox, whether you're sending 3,000 or 3,000,000 messages a day.\u003C\u002Fp>","591b1ef7-58f7-415c-8efb-0f9b2873faeb","2026-06-24T18:28:18.069791+03:00","2026-07-15T23:56:02.236069+03:00","Dedicated IP vs Shared IP","Dedicated IP email vs shared IP email: learn when each makes sense, volume thresholds, IP warm-up, reputation, and cost with a clear comparison table.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_591b1ef7-58f7-415c-8efb-0f9b2873faeb\u002Fdedicated-ip-vs-shared-ip.png","2026-08-03T09:00:00+03:00",[60,61],"dedicated ip email","shared ip email",{"id":63,"body":64,"uuid":65,"created_at":66,"updated_at":67,"brand":13,"header":68,"short_body":69,"image":70,"published":17,"published_at":71,"tags":72},19,"\u003Cp>Email bounce handling is the practice of detecting, classifying, and reacting to messages your mail server cannot deliver. If you run a SaaS product or ship transactional email from your app, ignoring bounces is one of the fastest ways to wreck your sender reputation, land in spam folders, and stop reaching real users. Good email bounce handling keeps your lists clean, protects your domain reputation, and ensures the password resets, receipts, and alerts your customers depend on actually arrive.\u003C\u002Fp>\n\u003Cp>This guide walks through everything an engineer needs: the difference between a hard bounce and a soft bounce, how to read bounce codes, how to build a suppression list, how to design retry logic, and how to process bounce webhooks with real code. By the end you will have a concrete, production-ready model for handling bounces in any transactional email system.\u003C\u002Fp>\n\u003Ch2>What Is an Email Bounce?\u003C\u002Fh2>\n\u003Cp>An email bounce occurs when a mail server rejects or fails to deliver a message and returns a notification—technically called a \u003Cstrong>Delivery Status Notification (DSN)\u003C\u002Fstrong> or \u003Cstrong>Non-Delivery Report (NDR)\u003C\u002Fstrong>—back to the sender. The bounce contains a status code and a human-readable reason explaining why delivery failed.\u003C\u002Fp>\n\u003Cp>Bounces fall into two broad categories that drive completely different responses:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Hard bounce\u003C\u002Fstrong> — a permanent failure. The address is invalid, the domain does not exist, or the mailbox is gone. You should never send to that address again.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Soft bounce\u003C\u002Fstrong> — a temporary failure. The mailbox is full, the server is down, or the message was greylisted. You can retry later.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Getting this distinction right is the foundation of email bounce handling. Treating a hard bounce as temporary means you keep hammering a dead address and damage your reputation. Treating a soft bounce as permanent means you suppress users who would have received mail just fine.\u003C\u002Fp>\n\u003Ch2>Hard Bounce vs Soft Bounce: The Core Distinction\u003C\u002Fh2>\n\u003Cp>The hard bounce soft bounce split determines whether you suppress an address forever or schedule a retry. Internet mail uses a structured status code defined in \u003Cstrong>RFC 3463\u003C\u002Fstrong> (Enhanced Mail System Status Codes) to communicate this. The code looks like \u003Ccode>class.subject.detail\u003C\u002Fcode>, for example \u003Ccode>5.1.1\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>The first digit—the \u003Cstrong>class\u003C\u002Fstrong>—is the most important signal:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>2.x.x\u003C\u002Fcode> — Success\u003C\u002Fli>\n\u003Cli>\u003Ccode>4.x.x\u003C\u002Fcode> — Persistent transient failure (soft bounce, retry)\u003C\u002Fli>\n\u003Cli>\u003Ccode>5.x.x\u003C\u002Fcode> — Permanent failure (hard bounce, suppress)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Comparison Table: Hard Bounce vs Soft Bounce\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Attribute\u003C\u002Fth>\n\u003Cth>Hard Bounce\u003C\u002Fth>\n\u003Cth>Soft Bounce\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Status class\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>5.x.x\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>4.x.x\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Nature\u003C\u002Ftd>\n\u003Ctd>Permanent\u003C\u002Ftd>\n\u003Ctd>Temporary\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Common causes\u003C\u002Ftd>\n\u003Ctd>Invalid address, domain doesn't exist, mailbox closed\u003C\u002Ftd>\n\u003Ctd>Mailbox full, server down, message too large, greylisting\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Correct response\u003C\u002Ftd>\n\u003Ctd>Suppress immediately\u003C\u002Ftd>\n\u003Ctd>Retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Reputation impact\u003C\u002Ftd>\n\u003Ctd>High if repeated\u003C\u002Ftd>\n\u003Ctd>Low to moderate\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Retry?\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003Ctd>Yes (with limits)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Example code\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>5.1.1\u003C\u002Fcode> (bad mailbox)\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>4.2.2\u003C\u002Fcode> (mailbox full)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>Common Hard Bounce Causes\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>The recipient address has a typo or never existed (\u003Ccode>5.1.1\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The recipient domain has no MX records or does not exist (\u003Ccode>5.1.2\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The mailbox has been deactivated or the employee left the company.\u003C\u002Fli>\n\u003Cli>The receiving server permanently blocked your domain or IP (\u003Ccode>5.7.1\u003C\u002Fcode>).\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Common Soft Bounce Causes\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>The recipient's mailbox is over quota (\u003Ccode>4.2.2\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The receiving mail server is temporarily unavailable or overloaded (\u003Ccode>4.4.1\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The message is too large for the recipient's limits (\u003Ccode>5.3.4\u003C\u002Fcode> is permanent, but some servers return \u003Ccode>4.x.x\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Greylisting\u003C\u002Fstrong> — the server deliberately defers first-time senders and expects a retry within a few minutes.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Understanding Bounce Codes\u003C\u002Fh2>\n\u003Cp>Bounce codes carry both a \u003Cstrong>reply code\u003C\u002Fstrong> (the SMTP three-digit code from RFC 5321) and an \u003Cstrong>enhanced status code\u003C\u002Fstrong> (the \u003Ccode>class.subject.detail\u003C\u002Fcode> from RFC 3463). You will see both in a real bounce.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>550 5.1.1 The email account that you tried to reach does not exist.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Here \u003Ccode>550\u003C\u002Fcode> is the SMTP reply code and \u003Ccode>5.1.1\u003C\u002Fcode> is the enhanced status code. Reading them together gives you a reliable, machine-parseable signal.\u003C\u002Fp>\n\u003Ch3>Reference Table: Frequently Seen Bounce Codes\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Enhanced code\u003C\u002Fth>\n\u003Cth>SMTP code\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Type\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.1.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Bad destination mailbox address\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.1.2\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Bad destination system \u002F domain\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.1.10\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Recipient address null \u002F does not accept mail\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.2.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Mailbox disabled\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.7.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u002F554\u003C\u002Ftd>\n\u003Ctd>Delivery not authorized \u002F blocked\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.2.2\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>452\u003C\u002Ftd>\n\u003Ctd>Mailbox full \u002F over quota\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.4.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>421\u003C\u002Ftd>\n\u003Ctd>Connection \u002F server unavailable\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.4.2\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>421\u003C\u002Ftd>\n\u003Ctd>Connection dropped\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.7.0\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>421\u003C\u002Ftd>\n\u003Ctd>Temporary policy \u002F rate limit\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Tip for featured snippets:\u003C\u002Fstrong> A bounce code starting with \u003Ccode>5\u003C\u002Fcode> is a \u003Cstrong>hard bounce\u003C\u002Fstrong> (permanent, suppress the address). A code starting with \u003Ccode>4\u003C\u002Fcode> is a \u003Cstrong>soft bounce\u003C\u002Fstrong> (temporary, retry later).\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>A practical caveat: not every receiving server follows the spec perfectly. Some return \u003Ccode>5.x.x\u003C\u002Fcode> codes for situations that are really temporary (a server briefly returning \u003Ccode>550\u003C\u002Fcode> under load), and some bury the real reason in the free-text portion. This is why mature email bounce handling combines the status class with text pattern matching and a retry threshold rather than trusting a single field.\u003C\u002Fp>\n\u003Ch2>Suppression Lists: Your First Line of Defense\u003C\u002Fh2>\n\u003Cp>A \u003Cstrong>suppression list\u003C\u002Fstrong> is a database of addresses you must never email again. Every hard bounce should add the address to this list, and your sending pipeline should check it before every send. This single mechanism prevents the most common reputation killer: repeatedly mailing dead addresses.\u003C\u002Fp>\n\u003Ch3>What Belongs on a Suppression List\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Hard bounces\u003C\u002Fstrong> — invalid or non-existent addresses.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Spam complaints\u003C\u002Fstrong> — recipients who hit \"mark as spam\" (received via Feedback Loops \u002F ARF reports).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Unsubscribes\u003C\u002Fstrong> — anyone who opted out, legally required under CAN-SPAM and GDPR.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Repeated soft bounces\u003C\u002Fstrong> — an address that soft-bounces consistently over many days behaves like a hard bounce and should be suppressed.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>A Minimal Suppression Schema\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-sql\">CREATE TABLE suppressions (\n    id            BIGSERIAL PRIMARY KEY,\n    email         TEXT NOT NULL UNIQUE,\n    reason        TEXT NOT NULL,          -- hard_bounce, complaint, unsubscribe, repeated_soft_bounce\n    bounce_code   TEXT,                   -- e.g. 5.1.1\n    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE INDEX idx_suppressions_email ON suppressions (email);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Check it on the hot path before queuing any message:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">def is_suppressed(db, email: str) -&gt; bool:\n    row = db.execute(\n        &quot;SELECT 1 FROM suppressions WHERE email = %s LIMIT 1&quot;,\n        (email.lower().strip(),),\n    ).fetchone()\n    return row is not None\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Always normalize addresses (lowercase, trim) before comparing, or duplicates and casing differences will leak through.\u003C\u002Fp>\n\u003Ch2>Designing Retry Logic for Soft Bounces\u003C\u002Fh2>\n\u003Cp>Soft bounces deserve a second chance—but not an infinite one. The right approach is \u003Cstrong>exponential backoff with a hard cap\u003C\u002Fstrong>. You retry a failing message with progressively longer delays, and after a set number of attempts or total elapsed time, you give up and treat the address as suppressed.\u003C\u002Fp>\n\u003Cp>A common, battle-tested schedule for transactional mail:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Attempt\u003C\u002Fth>\n\u003Cth>Delay after previous\u003C\u002Fth>\n\u003Cth>Total elapsed\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1\u003C\u002Ftd>\n\u003Ctd>immediate\u003C\u002Ftd>\n\u003Ctd>0\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>2\u003C\u002Ftd>\n\u003Ctd>15 minutes\u003C\u002Ftd>\n\u003Ctd>15 min\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3\u003C\u002Ftd>\n\u003Ctd>1 hour\u003C\u002Ftd>\n\u003Ctd>~1.25 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>4\u003C\u002Ftd>\n\u003Ctd>4 hours\u003C\u002Ftd>\n\u003Ctd>~5.25 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>5\u003C\u002Ftd>\n\u003Ctd>12 hours\u003C\u002Ftd>\n\u003Ctd>~17 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Stop\u003C\u002Ftd>\n\u003Ctd>—\u003C\u002Ftd>\n\u003Ctd>give up at ~24–72 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Two guardrails matter:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Cap total retry duration.\u003C\u002Fstrong> Transactional email (a password reset, a 2FA code) is worthless after a day. A 24–72 hour window is standard; beyond that, suppress.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Promote persistent soft bouncers to suppressed.\u003C\u002Fstrong> If an address soft-bounces on, say, five separate sends over a week, it is effectively dead. Add it to the suppression list with reason \u003Ccode>repeated_soft_bounce\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cpre>\u003Ccode class=\"language-python\">import math\n\nMAX_ATTEMPTS = 5\nBASE_DELAY_MINUTES = 15\n\ndef next_retry_delay_minutes(attempt: int) -&gt; int | None:\n    &quot;&quot;&quot;Exponential backoff. Returns None when retries are exhausted.&quot;&quot;&quot;\n    if attempt &gt;= MAX_ATTEMPTS:\n        return None\n    # 15, 30, 60, 120, 240 ... minutes\n    return BASE_DELAY_MINUTES * int(math.pow(2, attempt))\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>When \u003Ccode>next_retry_delay_minutes\u003C\u002Fcode> returns \u003Ccode>None\u003C\u002Fcode>, stop retrying and suppress if the pattern warrants it.\u003C\u002Fp>\n\u003Ch2>Reputation Impact: Why Bounces Matter\u003C\u002Fh2>\n\u003Cp>Mailbox providers like Gmail, Outlook, and Yahoo track your bounce rate as a core signal of list hygiene. A high bounce rate tells them you are mailing addresses you do not maintain—a classic spammer behavior. The consequences compound:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Lower inbox placement\u003C\u002Fstrong> — your legitimate mail starts landing in spam.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Throttling\u003C\u002Fstrong> — providers slow down or temporarily reject your messages (\u003Ccode>4.7.0\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocklisting\u003C\u002Fstrong> — your domain or IP gets added to a blocklist, hard-bouncing everything.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Google's own sender guidelines, in effect since February 2024, ask bulk senders to \u003Cstrong>keep spam complaint rates below 0.3%\u003C\u002Fstrong> and to maintain proper authentication (SPF, DKIM, DMARC). While Google publishes a complaint-rate target rather than a single official bounce-rate number, the industry consensus from deliverability vendors is to \u003Cstrong>keep your bounce rate under 2%\u003C\u002Fstrong>, and ideally under 1%. Above ~5% you are in danger territory and many platforms will pause your account.\u003C\u002Fp>\n\u003Cp>The math is simple: every hard bounce you could have prevented by maintaining a suppression list is a self-inflicted hit to your reputation. Email bounce handling is not cleanup work—it is reputation insurance.\u003C\u002Fp>\n\u003Ch3>Authentication Reduces Bounces Too\u003C\u002Fh3>\n\u003Cp>Some \u003Ccode>5.7.x\u003C\u002Fcode> bounces are authentication failures, not bad addresses. Configuring \u003Cstrong>SPF\u003C\u002Fstrong>, \u003Cstrong>DKIM\u003C\u002Fstrong>, and \u003Cstrong>DMARC\u003C\u002Fstrong> correctly prevents receiving servers from rejecting your legitimate mail as unauthenticated. Authentication and bounce handling are two halves of deliverability.\u003C\u002Fp>\n\u003Ch2>Processing Bounce Webhooks\u003C\u002Fh2>\n\u003Cp>Modern email APIs do not make you parse raw DSN emails. Instead, they POST a structured JSON event to a \u003Cstrong>webhook\u003C\u002Fstrong> URL whenever a message bounces. Your job is to receive that event, classify it, and update your suppression list and retry queue.\u003C\u002Fp>\n\u003Cp>A typical bounce webhook payload looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;event&quot;: &quot;bounce&quot;,\n  &quot;message_id&quot;: &quot;a1b2c3d4-0000-1111-2222-333344445555&quot;,\n  &quot;recipient&quot;: &quot;user@example.com&quot;,\n  &quot;bounce_type&quot;: &quot;hard&quot;,\n  &quot;smtp_code&quot;: &quot;550&quot;,\n  &quot;status_code&quot;: &quot;5.1.1&quot;,\n  &quot;diagnostic&quot;: &quot;550 5.1.1 The email account that you tried to reach does not exist.&quot;,\n  &quot;timestamp&quot;: &quot;2026-06-24T10:15:00Z&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 1: Verify the Webhook Signature\u003C\u002Fh3>\n\u003Cp>Never trust an unauthenticated webhook. Anyone who discovers your endpoint could otherwise suppress your entire user base. Most providers sign the request body with an HMAC you can verify.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hashlib\nimport hmac\n\ndef verify_signature(secret: str, payload: bytes, signature: str) -&gt; bool:\n    expected = hmac.new(\n        secret.encode(&quot;utf-8&quot;),\n        payload,\n        hashlib.sha256,\n    ).hexdigest()\n    return hmac.compare_digest(expected, signature)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Use \u003Ccode>hmac.compare_digest\u003C\u002Fcode> rather than \u003Ccode>==\u003C\u002Fcode> to avoid timing attacks.\u003C\u002Fp>\n\u003Ch3>Step 2: Classify the Bounce\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-python\">def classify_bounce(status_code: str, diagnostic: str) -&gt; str:\n    &quot;&quot;&quot;Return 'hard', 'soft', or 'unknown' from an enhanced status code.&quot;&quot;&quot;\n    code = (status_code or &quot;&quot;).strip()\n\n    if code.startswith(&quot;5&quot;):\n        return &quot;hard&quot;\n    if code.startswith(&quot;4&quot;):\n        return &quot;soft&quot;\n\n    # Fall back to text matching when the code is missing or malformed.\n    text = (diagnostic or &quot;&quot;).lower()\n    hard_signals = (&quot;does not exist&quot;, &quot;no such user&quot;, &quot;unknown user&quot;,\n                    &quot;mailbox unavailable&quot;, &quot;user unknown&quot;, &quot;no mailbox&quot;)\n    soft_signals = (&quot;mailbox full&quot;, &quot;over quota&quot;, &quot;try again&quot;,\n                    &quot;temporarily&quot;, &quot;rate limit&quot;, &quot;greylist&quot;)\n\n    if any(s in text for s in hard_signals):\n        return &quot;hard&quot;\n    if any(s in text for s in soft_signals):\n        return &quot;soft&quot;\n    return &quot;unknown&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 3: Act on the Classification\u003C\u002Fh3>\n\u003Cp>Here is a complete Flask endpoint that ties verification, classification, suppression, and retry together. All imports are at the top of the file.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hashlib\nimport hmac\nimport os\n\nfrom flask import Flask, request, abort, jsonify\n\napp = Flask(__name__)\n\nWEBHOOK_SECRET = os.environ[&quot;BOUNCE_WEBHOOK_SECRET&quot;]\nSOFT_BOUNCE_SUPPRESS_THRESHOLD = 5\n\n\ndef verify_signature(secret: str, payload: bytes, signature: str) -&gt; bool:\n    expected = hmac.new(secret.encode(&quot;utf-8&quot;), payload, hashlib.sha256).hexdigest()\n    return hmac.compare_digest(expected, signature)\n\n\ndef classify_bounce(status_code: str, diagnostic: str) -&gt; str:\n    code = (status_code or &quot;&quot;).strip()\n    if code.startswith(&quot;5&quot;):\n        return &quot;hard&quot;\n    if code.startswith(&quot;4&quot;):\n        return &quot;soft&quot;\n    text = (diagnostic or &quot;&quot;).lower()\n    if any(s in text for s in (&quot;does not exist&quot;, &quot;no such user&quot;, &quot;user unknown&quot;)):\n        return &quot;hard&quot;\n    if any(s in text for s in (&quot;mailbox full&quot;, &quot;over quota&quot;, &quot;try again&quot;, &quot;temporarily&quot;)):\n        return &quot;soft&quot;\n    return &quot;unknown&quot;\n\n\ndef suppress(email: str, reason: str, code: str) -&gt; None:\n    # Insert into the suppressions table; ignore if already present.\n    db.execute(\n        &quot;&quot;&quot;INSERT INTO suppressions (email, reason, bounce_code)\n           VALUES (%s, %s, %s)\n           ON CONFLICT (email) DO NOTHING&quot;&quot;&quot;,\n        (email.lower().strip(), reason, code),\n    )\n\n\ndef record_soft_bounce(email: str) -&gt; int:\n    # Increment and return the running soft-bounce count for this address.\n    row = db.execute(\n        &quot;&quot;&quot;INSERT INTO soft_bounce_counts (email, count)\n           VALUES (%s, 1)\n           ON CONFLICT (email) DO UPDATE SET count = soft_bounce_counts.count + 1\n           RETURNING count&quot;&quot;&quot;,\n        (email.lower().strip(),),\n    ).fetchone()\n    return row[0]\n\n\n@app.route(&quot;\u002Fwebhooks\u002Fbounce&quot;, methods=[&quot;POST&quot;])\ndef handle_bounce():\n    signature = request.headers.get(&quot;X-Signature&quot;, &quot;&quot;)\n    if not verify_signature(WEBHOOK_SECRET, request.get_data(), signature):\n        abort(401)\n\n    event = request.get_json(force=True)\n    if event.get(&quot;event&quot;) != &quot;bounce&quot;:\n        return jsonify({&quot;status&quot;: &quot;ignored&quot;}), 200\n\n    email = event[&quot;recipient&quot;]\n    code = event.get(&quot;status_code&quot;, &quot;&quot;)\n    diagnostic = event.get(&quot;diagnostic&quot;, &quot;&quot;)\n    bounce_type = classify_bounce(code, diagnostic)\n\n    if bounce_type == &quot;hard&quot;:\n        suppress(email, &quot;hard_bounce&quot;, code)\n    elif bounce_type == &quot;soft&quot;:\n        count = record_soft_bounce(email)\n        if count &gt;= SOFT_BOUNCE_SUPPRESS_THRESHOLD:\n            suppress(email, &quot;repeated_soft_bounce&quot;, code)\n        # else: leave it for the retry queue to pick up.\n\n    return jsonify({&quot;status&quot;: &quot;processed&quot;, &quot;type&quot;: bounce_type}), 200\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 4: Return 200 Fast and Process Async\u003C\u002Fh3>\n\u003Cp>Webhook providers retry if you do not respond quickly. For heavy work (database writes, notifications), acknowledge with \u003Ccode>200\u003C\u002Fcode> immediately and push the event onto a queue for a worker to process. This keeps the endpoint snappy and avoids duplicate deliveries from provider retries. Make your handler \u003Cstrong>idempotent\u003C\u002Fstrong>—use the \u003Ccode>message_id\u003C\u002Fcode> as a dedup key—because providers can and do send the same event more than once.\u003C\u002Fp>\n\u003Ch2>Common Mistakes in Email Bounce Handling\u003C\u002Fh2>\n\u003Cp>Even experienced teams trip over these. Avoid them:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Treating all bounces the same.\u003C\u002Fstrong> Suppressing soft bounces loses real users; retrying hard bounces destroys your reputation. Always classify first.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No suppression list at all.\u003C\u002Fstrong> Re-mailing addresses that already hard-bounced is the single fastest way to get blocklisted.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Trusting the status class blindly.\u003C\u002Fstrong> Some servers misreport. Combine the \u003Ccode>4.x\u002F5.x\u003C\u002Fcode> class with text matching and a retry threshold.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Infinite retries.\u003C\u002Fstrong> Retrying a soft bounce forever wastes resources and signals spammy behavior. Always cap attempts and total duration.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping webhook signature verification.\u003C\u002Fstrong> An unsigned endpoint lets attackers suppress your users or inject fake events.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Non-idempotent handlers.\u003C\u002Fstrong> Providers resend events. Without dedup on \u003Ccode>message_id\u003C\u002Fcode>, you double-count soft bounces and may suppress prematurely.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring spam complaints.\u003C\u002Fstrong> Complaints hurt reputation more than bounces. Feed Feedback Loop (ARF) reports into the same suppression list.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Forgetting to normalize addresses.\u003C\u002Fstrong> \u003Ccode>User@Example.com\u003C\u002Fcode> and \u003Ccode>user@example.com\u003C\u002Fcode> are the same mailbox; case-sensitive checks let dupes slip through.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No monitoring.\u003C\u002Fstrong> If you do not chart your bounce rate over time, you will not notice a bad import or a broken signup form until your reputation is already damaged.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>FAQ\u003C\u002Fh2>\n\u003Ch3>What is the difference between a hard bounce and a soft bounce?\u003C\u002Fh3>\n\u003Cp>A \u003Cstrong>hard bounce\u003C\u002Fstrong> is a permanent delivery failure (status code \u003Ccode>5.x.x\u003C\u002Fcode>)—the address is invalid or no longer exists, so you should suppress it and never send to it again. A \u003Cstrong>soft bounce\u003C\u002Fstrong> is a temporary failure (status code \u003Ccode>4.x.x\u003C\u002Fcode>)—the mailbox is full or the server is briefly down, so you can retry later. The hard bounce soft bounce distinction is the core of email bounce handling.\u003C\u002Fp>\n\u003Ch3>What is a good email bounce rate?\u003C\u002Fh3>\n\u003Cp>Aim to keep your overall bounce rate \u003Cstrong>under 2%\u003C\u002Fstrong>, and ideally under 1%. Above roughly 5%, many email platforms will throttle or pause your account, and mailbox providers may start filtering your mail to spam. Lower is always better and reflects good list hygiene.\u003C\u002Fp>\n\u003Ch3>How do I read an email bounce code?\u003C\u002Fh3>\n\u003Cp>A bounce includes an SMTP reply code (e.g. \u003Ccode>550\u003C\u002Fcode>) and an enhanced status code in \u003Ccode>class.subject.detail\u003C\u002Fcode> format (e.g. \u003Ccode>5.1.1\u003C\u002Fcode>) from RFC 3463. The first digit is what matters most: \u003Ccode>5\u003C\u002Fcode> means a permanent hard bounce, \u003Ccode>4\u003C\u002Fcode> means a temporary soft bounce, and \u003Ccode>2\u003C\u002Fcode> means success.\u003C\u002Fp>\n\u003Ch3>Should I retry sending after a bounce?\u003C\u002Fh3>\n\u003Cp>Retry only \u003Cstrong>soft bounces\u003C\u002Fstrong> (\u003Ccode>4.x.x\u003C\u002Fcode>), using exponential backoff with a cap—for example 15 minutes, then 1 hour, then 4 hours, giving up after about 24–72 hours. Never retry \u003Cstrong>hard bounces\u003C\u002Fstrong> (\u003Ccode>5.x.x\u003C\u002Fcode>); add those addresses to your suppression list immediately.\u003C\u002Fp>\n\u003Ch3>What is a suppression list and do I need one?\u003C\u002Fh3>\n\u003Cp>A suppression list is a database of addresses you must never email again—hard bounces, spam complainers, and unsubscribes. Yes, you need one. Checking it before every send prevents the reputation damage that comes from re-mailing dead addresses, and it is required for unsubscribe compliance under CAN-SPAM and GDPR.\u003C\u002Fp>\n\u003Ch3>How do bounces affect my sender reputation?\u003C\u002Fh3>\n\u003Cp>Mailbox providers treat high bounce rates as a signal that you do not maintain your lists—classic spammer behavior. The result is lower inbox placement, throttling, and eventually blocklisting. Proactive email bounce handling, combined with SPF, DKIM, and DMARC authentication, protects your reputation.\u003C\u002Fp>\n\u003Ch3>How do I process bounces from a webhook?\u003C\u002Fh3>\n\u003Cp>Receive the provider's JSON event, verify its HMAC signature, classify the bounce as hard or soft from its status code, then suppress hard bounces and queue soft bounces for retry. Respond with \u003Ccode>200\u003C\u002Fcode> quickly, process heavy work asynchronously, and make the handler idempotent using the \u003Ccode>message_id\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>Can soft bounces ever become hard bounces?\u003C\u002Fh3>\n\u003Cp>Yes. If an address soft-bounces repeatedly over days or weeks—say five or more times—it behaves like a permanently dead mailbox. Promote it to your suppression list with a reason like \u003Ccode>repeated_soft_bounce\u003C\u002Fcode> so you stop wasting sends and protect your reputation.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Email bounce handling is not optional plumbing—it is core to whether your transactional email reaches users at all. The model is straightforward once you internalize it:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Classify\u003C\u002Fstrong> every bounce as hard (\u003Ccode>5.x.x\u003C\u002Fcode>) or soft (\u003Ccode>4.x.x\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Suppress\u003C\u002Fstrong> hard bounces immediately and never mail them again.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retry\u003C\u002Fstrong> soft bounces with exponential backoff and a hard cap.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Promote\u003C\u002Fstrong> persistent soft bouncers to your suppression list.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Verify\u003C\u002Fstrong> webhooks, make handlers idempotent, and process asynchronously.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Monitor\u003C\u002Fstrong> your bounce rate and keep it under 2%.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Do these consistently and you protect your sender reputation, keep your lists clean, and ensure the password resets, receipts, and alerts your customers rely on actually land in their inboxes.\u003C\u002Fp>\n\u003Ch2>Send Reliable Transactional Email with Postwing\u003C\u002Fh2>\n\u003Cp>Building robust email bounce handling from scratch—DSN parsing, suppression lists, retry queues, signed webhooks—is real engineering work. \u003Cstrong>Postwing\u003C\u002Fstrong> handles it for you. Our transactional email platform automatically classifies hard and soft bounces, maintains your suppression list, manages retry logic, and delivers clean, signed bounce and complaint webhooks straight to your app, with SPF, DKIM, and DMARC handled out of the box.\u003C\u002Fp>\n\u003Cp>Postwing is built for developers and SaaS teams, with a simple API, transparent deliverability metrics, and \u003Cstrong>USDC (crypto) payments on Base\u003C\u002Fstrong>—no credit card required, no surprise billing. Spend your time shipping features, not parsing bounce codes.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","052638d7-e7b6-4fef-b6e9-ea4e497f1b33","2026-06-24T18:28:18.065422+03:00","2026-07-15T23:56:23.854116+03:00","How to Handle Email Bounces","Master email bounce handling: hard vs soft bounces, bounce codes, suppression lists, retry logic, and webhook processing with practical code examples.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_052638d7-e7b6-4fef-b6e9-ea4e497f1b33\u002Fhandle-email-bounces.png","2026-07-30T09:00:00+03:00",[73,74],"email bounce handling","hard bounce soft bounce",{"id":4,"body":76,"uuid":77,"created_at":78,"updated_at":79,"brand":13,"header":80,"short_body":81,"image":82,"published":17,"published_at":83,"tags":84},"\u003Cp>Transactional email templates are the silent workhorses of every SaaS product. Password resets, receipts, verification codes, shipping notifications, and alerts all flow through them, and unlike marketing campaigns, they are \u003Cem>expected\u003C\u002Fem>, time-sensitive, and tied directly to user trust. Yet most engineering teams treat transactional email templates as an afterthought, copy-pasting brittle HTML until a Gmail update breaks the layout or a screen reader user complains that the verification code is unreadable.\u003C\u002Fp>\n\u003Cp>This guide collects the email template best practices that actually matter for developers: building responsive HTML that survives 30+ email clients, using MJML and templating engines to stay sane, shipping plain-text alternatives, getting accessibility and dark mode right, wiring up dynamic variables safely, and testing before you hit production. Every section includes code you can paste into a real project.\u003C\u002Fp>\n\u003Ch2>Why Transactional Email Templates Deserve Engineering Effort\u003C\u002Fh2>\n\u003Cp>Transactional emails have open rates between 80% and 90%, according to data widely reported across email infrastructure providers, compared to 20–30% for promotional email. A user who requests a password reset \u003Cem>will\u003C\u002Fem> open that message. If the layout is broken, the CTA button is invisible in dark mode, or the email lands in spam, you have a support ticket or a churned user.\u003C\u002Fp>\n\u003Cp>The technical reality is that email rendering is stuck in the late 1990s. Email clients do not run a modern browser engine. Outlook on Windows uses Microsoft Word's HTML renderer. Gmail strips \u003Ccode>&lt;style&gt;\u003C\u002Fcode> tags in certain contexts and rewrites your CSS. Apple Mail is closer to a real browser but still has quirks. This is why HTML email development feels like time travel: you build with tables, inline styles, and a defensive mindset.\u003C\u002Fp>\n\u003Cp>Getting transactional email templates right means three things working together:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Rendering\u003C\u002Fstrong> — the message looks correct everywhere it is opened.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Deliverability\u003C\u002Fstrong> — the message reaches the inbox, not spam.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Maintainability\u003C\u002Fstrong> — your team can change a template without fear.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>The rest of this article is organized around those goals.\u003C\u002Fp>\n\u003Ch2>Responsive HTML Email: The Foundation\u003C\u002Fh2>\n\u003Cp>More than half of all email opens happen on mobile devices. A transactional email that requires horizontal scrolling on a phone is a failed email. Responsive HTML email is non-negotiable.\u003C\u002Fp>\n\u003Ch3>Use Tables for Layout, Not Divs\u003C\u002Fh3>\n\u003Cp>This is the single most counterintuitive rule for web developers. In email, \u003Ccode>&lt;table&gt;\u003C\u002Fcode> is your layout grid because Outlook ignores \u003Ccode>float\u003C\u002Fcode>, \u003Ccode>flexbox\u003C\u002Fcode>, and \u003Ccode>grid\u003C\u002Fcode> entirely. A robust single-column layout looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;!DOCTYPE html&gt;\n&lt;html lang=&quot;en&quot; xmlns=&quot;http:\u002F\u002Fwww.w3.org\u002F1999\u002Fxhtml&quot;&gt;\n&lt;head&gt;\n  &lt;meta charset=&quot;utf-8&quot;&gt;\n  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;\n  &lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=edge&quot;&gt;\n  &lt;title&gt;Your verification code&lt;\u002Ftitle&gt;\n&lt;\u002Fhead&gt;\n&lt;body style=&quot;margin:0; padding:0; background-color:#f4f4f7;&quot;&gt;\n  &lt;table role=&quot;presentation&quot; width=&quot;100%&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot; border=&quot;0&quot;&gt;\n    &lt;tr&gt;\n      &lt;td align=&quot;center&quot; style=&quot;padding: 24px 12px;&quot;&gt;\n        &lt;table role=&quot;presentation&quot; width=&quot;600&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot;\n               border=&quot;0&quot; style=&quot;max-width:600px; width:100%; background:#ffffff;\n               border-radius:8px;&quot;&gt;\n          &lt;tr&gt;\n            &lt;td style=&quot;padding: 32px; font-family: Arial, Helvetica, sans-serif;\n                       color:#1a1a1a; font-size:16px; line-height:24px;&quot;&gt;\n              &lt;h1 style=&quot;margin:0 0 16px; font-size:22px;&quot;&gt;Verify your email&lt;\u002Fh1&gt;\n              &lt;p style=&quot;margin:0 0 24px;&quot;&gt;Use the code below to finish signing in.&lt;\u002Fp&gt;\n              &lt;p style=&quot;font-size:28px; letter-spacing:6px; font-weight:bold;\n                        margin:0;&quot;&gt;{{ code }}&lt;\u002Fp&gt;\n            &lt;\u002Ftd&gt;\n          &lt;\u002Ftr&gt;\n        &lt;\u002Ftable&gt;\n      &lt;\u002Ftd&gt;\n    &lt;\u002Ftr&gt;\n  &lt;\u002Ftable&gt;\n&lt;\u002Fbody&gt;\n&lt;\u002Fhtml&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Key points in that snippet:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>role=\"presentation\"\u003C\u002Fcode> tells screen readers the table is for layout, not data.\u003C\u002Fli>\n\u003Cli>Every style is \u003Cstrong>inline\u003C\u002Fstrong>. Email clients strip or mangle external and embedded CSS.\u003C\u002Fli>\n\u003Cli>\u003Ccode>width=\"600\"\u003C\u002Fcode> with \u003Ccode>max-width:600px; width:100%\u003C\u002Fcode> keeps desktop fixed and mobile fluid.\u003C\u002Fli>\n\u003Cli>The \u003Ccode>viewport\u003C\u002Fcode> meta tag enables responsive scaling on mobile.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Add Media Queries for Mobile Refinement\u003C\u002Fh3>\n\u003Cp>While inline styles handle the baseline, a \u003Ccode>&lt;style&gt;\u003C\u002Fcode> block in the \u003Ccode>&lt;head&gt;\u003C\u002Fcode> with media queries lets you refine mobile layouts in clients that support them (Apple Mail, modern Gmail apps). Always treat these as \u003Cem>progressive enhancement\u003C\u002Fem> — the email must still look acceptable if they are ignored.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;style&gt;\n  @media only screen and (max-width: 600px) {\n    .container { width: 100% !important; }\n    .px-32 { padding-left: 16px !important; padding-right: 16px !important; }\n    .stack { display: block !important; width: 100% !important; }\n  }\n&lt;\u002Fstyle&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>The 600px Rule\u003C\u002Fh3>\n\u003Cp>The de facto standard width for email is \u003Cstrong>600px\u003C\u002Fstrong>. It fits the Outlook reading pane and scales cleanly on mobile. Do not exceed it. Keep your single content column at or below 600px and you eliminate an entire class of rendering bugs.\u003C\u002Fp>\n\u003Ch2>MJML and Templating Engines: Stop Writing Raw Table HTML\u003C\u002Fh2>\n\u003Cp>Hand-writing nested tables is error-prone and miserable to maintain. \u003Ca href=\"https:\u002F\u002Fmjml.io\u002F\">MJML\u003C\u002Fa> is an open-source markup language by Mailjet that compiles a clean, semantic syntax down to bulletproof, responsive table HTML. It handles the Outlook conditional comments, the inlining, and the responsive math for you.\u003C\u002Fp>\n\u003Cp>The same verification email in MJML:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-xml\">&lt;mjml&gt;\n  &lt;mj-body background-color=&quot;#f4f4f7&quot;&gt;\n    &lt;mj-section background-color=&quot;#ffffff&quot; border-radius=&quot;8px&quot; padding=&quot;32px&quot;&gt;\n      &lt;mj-column&gt;\n        &lt;mj-text font-size=&quot;22px&quot; font-weight=&quot;bold&quot;&gt;Verify your email&lt;\u002Fmj-text&gt;\n        &lt;mj-text font-size=&quot;16px&quot; color=&quot;#1a1a1a&quot;&gt;\n          Use the code below to finish signing in.\n        &lt;\u002Fmj-text&gt;\n        &lt;mj-text font-size=&quot;28px&quot; font-weight=&quot;bold&quot; letter-spacing=&quot;6px&quot;&gt;\n          {{ code }}\n        &lt;\u002Fmj-text&gt;\n      &lt;\u002Fmj-column&gt;\n    &lt;\u002Fmj-section&gt;\n  &lt;\u002Fmj-body&gt;\n&lt;\u002Fmjml&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>That compiles to roughly 100 lines of battle-tested HTML. MJML is the closest thing the industry has to a standard for maintainable email markup.\u003C\u002Fp>\n\u003Ch3>Combine MJML With a Templating Engine\u003C\u002Fh3>\n\u003Cp>MJML handles \u003Cem>structure\u003C\u002Fem>; a templating engine handles \u003Cem>data\u003C\u002Fem>. Most teams pair MJML with Handlebars, Liquid, Nunjucks, or their backend's native engine (Jinja2 in Python, ERB in Rails, Go's \u003Ccode>html\u002Ftemplate\u003C\u002Fcode>). The workflow is:\u003C\u002Fp>\n\u003Col>\n\u003Cli>Author \u003Ccode>.mjml\u003C\u002Fcode> files with \u003Ccode>{{ variable }}\u003C\u002Fcode> placeholders.\u003C\u002Fli>\n\u003Cli>Compile MJML → HTML at build time (or cache the compiled output).\u003C\u002Fli>\n\u003Cli>Render the HTML through the templating engine with per-message data at send time.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3>Choosing an Approach\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Approach\u003C\u002Fth>\n\u003Cth>Maintainability\u003C\u002Fth>\n\u003Cth>Learning curve\u003C\u002Fth>\n\u003Cth>Best for\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Raw table HTML\u003C\u002Ftd>\n\u003Ctd>Low\u003C\u002Ftd>\n\u003Ctd>High (quirks)\u003C\u002Ftd>\n\u003Ctd>One-off, fully custom emails\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>MJML\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Low\u003C\u002Ftd>\n\u003Ctd>Most SaaS transactional email\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Maizzle (Tailwind)\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Teams already using Tailwind\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>React Email \u002F JSX\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Low for React devs\u003C\u002Ftd>\n\u003Ctd>React\u002FNext.js codebases\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Hosted drag-and-drop builder\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Low\u003C\u002Ftd>\n\u003Ctd>Non-technical marketing teams\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>For a developer-led SaaS, MJML or React Email are the strongest picks because templates live in your repo, get code review, and version with your application.\u003C\u002Fp>\n\u003Ch2>Plain-Text Alternatives Are Not Optional\u003C\u002Fh2>\n\u003Cp>Every transactional email should be a \u003Cstrong>multipart\u002Falternative\u003C\u002Fstrong> message carrying both an HTML part and a plain-text part. Skipping the plain-text version hurts you in three ways:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Deliverability\u003C\u002Fstrong> — spam filters penalize HTML-only messages. A matching text part is a positive signal.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Accessibility\u003C\u002Fstrong> — some users and clients prefer or default to plain text.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Fallback\u003C\u002Fstrong> — smartwatches, terminal mail clients, and previews use the text part.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Do not auto-strip tags from your HTML and call it a plain-text version; the result is usually garbled. Write a deliberate text version:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">Verify your email\n\nUse the code below to finish signing in:\n\n  482913\n\nThis code expires in 10 minutes. If you didn't request it,\nyou can safely ignore this email.\n\n— The Acme Team\nhttps:\u002F\u002Facme.app\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Keep the text version's \u003Cem>content\u003C\u002Fem> in sync with the HTML. A good templating setup renders both from the same data so the verification code can never diverge between parts.\u003C\u002Fp>\n\u003Ch2>Accessibility: Emails Everyone Can Read\u003C\u002Fh2>\n\u003Cp>Accessibility is both an ethical requirement and, in many jurisdictions, a legal one. Transactional emails carry critical information — receipts, security codes, account changes — so they must work with screen readers and for low-vision users.\u003C\u002Fp>\n\u003Cp>Checklist for accessible email templates:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Set the language\u003C\u002Fstrong>: \u003Ccode>&lt;html lang=\"en\"&gt;\u003C\u002Fcode> so screen readers use the right pronunciation.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Use semantic headings\u003C\u002Fstrong> (\u003Ccode>&lt;h1&gt;\u003C\u002Fcode>, \u003Ccode>&lt;h2&gt;\u003C\u002Fcode>) instead of bold paragraphs so users can navigate structure.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Provide real \u003Ccode>alt\u003C\u002Fcode> text\u003C\u002Fstrong> on meaningful images; use \u003Ccode>alt=\"\"\u003C\u002Fcode> on decorative ones so they are skipped.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mark layout tables\u003C\u002Fstrong> with \u003Ccode>role=\"presentation\"\u003C\u002Fcode> so they are not announced as data tables.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Maintain contrast\u003C\u002Fstrong>: body text should meet WCAG AA (4.5:1 contrast ratio). Light gray text on white fails.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep a minimum 14–16px body font\u003C\u002Fstrong> so text is legible without zooming.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Make links descriptive\u003C\u002Fstrong>: \"View your receipt\" beats \"click here\".\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Logical reading order\u003C\u002Fstrong>: the DOM order should match the visual order, since screen readers follow the source.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For tappable buttons, use a \"bulletproof button\" built from a styled \u003Ccode>&lt;a&gt;\u003C\u002Fcode> inside a table cell rather than an image, so it remains readable, clickable, and high-contrast even when images are blocked:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;table role=&quot;presentation&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot; border=&quot;0&quot;&gt;\n  &lt;tr&gt;\n    &lt;td align=&quot;center&quot; bgcolor=&quot;#2563eb&quot; style=&quot;border-radius:6px;&quot;&gt;\n      &lt;a href=&quot;{{ reset_url }}&quot;\n         style=&quot;display:inline-block; padding:14px 28px; font-family:Arial,sans-serif;\n                font-size:16px; color:#ffffff; text-decoration:none; font-weight:bold;&quot;&gt;\n        Reset your password\n      &lt;\u002Fa&gt;\n    &lt;\u002Ftd&gt;\n  &lt;\u002Ftr&gt;\n&lt;\u002Ftable&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Dark Mode: Designing for Both Themes\u003C\u002Fh2>\n\u003Cp>Roughly a third of users run their devices in dark mode, and email clients handle it inconsistently. Three behaviors exist in the wild:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>No change\u003C\u002Fstrong> (older Outlook): your email renders as designed.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Partial color inversion\u003C\u002Fstrong> (some Gmail\u002FOutlook): the client swaps certain backgrounds and text.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Full custom theming\u003C\u002Fstrong> (Apple Mail, iOS Mail): respects your \u003Ccode>prefers-color-scheme\u003C\u002Fcode> styles.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>The most dangerous failure mode is a dark logo or dark text on a background that the client inverts to dark — turning your content invisible. Best practices:\u003C\u002Fp>\n\u003Ch3>Declare Dark Mode Support\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;meta name=&quot;color-scheme&quot; content=&quot;light dark&quot;&gt;\n&lt;meta name=&quot;supported-color-schemes&quot; content=&quot;light dark&quot;&gt;\n&lt;style&gt;\n  @media (prefers-color-scheme: dark) {\n    .email-bg { background-color: #1a1a1a !important; }\n    .email-text { color: #f4f4f7 !important; }\n    .card { background-color: #2a2a2a !important; }\n  }\n&lt;\u002Fstyle&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Practical Dark Mode Tips\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Use transparent PNG logos with a light \"halo\"\u003C\u002Fstrong> or provide a version that reads on both light and dark backgrounds.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Avoid pure black (#000) and pure white (#fff)\u003C\u002Fstrong> for large areas; off-black (#1a1a1a) and off-white (#f4f4f7) reduce harsh inversion artifacts.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Test the inversion\u003C\u002Fstrong>, not just your intended dark styles — clients that auto-invert ignore your media query.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep buttons high-contrast\u003C\u002Fstrong> in both themes; a mid-blue (#2563eb) generally survives inversion well.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Dynamic Variables: Personalization Done Safely\u003C\u002Fh2>\n\u003Cp>Transactional emails are inherently dynamic — they carry a user's name, an order total, a one-time code, a tracking link. Handling these variables correctly is where security and correctness meet.\u003C\u002Fp>\n\u003Ch3>Escape Untrusted Data\u003C\u002Fh3>\n\u003Cp>User-supplied values (display names, addresses, support messages) must be \u003Cstrong>HTML-escaped\u003C\u002Fstrong> when interpolated into the HTML part, or you open an HTML\u002FCSS injection hole. Use a templating engine that auto-escapes by default (Jinja2, Handlebars \u003Ccode>{{ }}\u003C\u002Fcode>, Go \u003Ccode>html\u002Ftemplate\u003C\u002Fcode>). Reserve \"raw\"\u002Funescaped output (Handlebars \u003Ccode>{{{ }}}\u003C\u002Fcode>, Jinja \u003Ccode>| safe\u003C\u002Fcode>) only for HTML you generate yourself.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># Jinja2 auto-escapes {{ user_name }} by default — safe against injection\nfrom jinja2 import Environment, FileSystemLoader, select_autoescape\n\nenv = Environment(\n    loader=FileSystemLoader(&quot;templates&quot;),\n    autoescape=select_autoescape([&quot;html&quot;, &quot;xml&quot;]),\n)\n\nhtml = env.get_template(&quot;receipt.html&quot;).render(\n    user_name=order.customer_name,   # escaped automatically\n    total=f&quot;${order.total:.2f}&quot;,\n    items=order.line_items,\n)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Provide Fallbacks and Format on the Server\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Always default\u003C\u002Fstrong> optional fields: \u003Ccode>{{ first_name | default(\"there\") }}\u003C\u002Fcode> prevents \"Hi ,\".\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Format numbers, dates, and currency server-side\u003C\u002Fstrong> in the user's locale before passing them in. Templates should display, not compute.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Never put secrets in URLs that get logged.\u003C\u002Fstrong> One-time tokens in reset links should be single-use and short-lived.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Validate that required variables exist\u003C\u002Fstrong> before sending; a missing \u003Ccode>{{ code }}\u003C\u002Fcode> in a 2FA email is a production incident.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Keep Logic Out of Templates\u003C\u002Fh3>\n\u003Cp>Conditionals are fine (\u003Ccode>{% if invoice.discount %}\u003C\u002Fcode>), but business logic belongs in your application code. Templates that compute tax or decide eligibility become untestable and brittle. Pass in a fully prepared view-model.\u003C\u002Fp>\n\u003Ch2>Testing Transactional Email Templates\u003C\u002Fh2>\n\u003Cp>You cannot eyeball your way to correct email rendering across 30+ clients. Testing must be systematic.\u003C\u002Fp>\n\u003Ch3>Layers of Testing\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Layer\u003C\u002Fth>\n\u003Cth>What it catches\u003C\u002Fth>\n\u003Cth>Tools\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Local preview\u003C\u002Ftd>\n\u003Ctd>Obvious layout\u002Fdata errors\u003C\u002Ftd>\n\u003Ctd>MJML CLI, maildev, Mailpit, Ethereal\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>HTML\u002FCSS linting\u003C\u002Ftd>\n\u003Ctd>Unsupported CSS, broken tags\u003C\u002Ftd>\n\u003Ctd>MJML validator, can-i-email checks\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Rendering matrix\u003C\u002Ftd>\n\u003Ctd>Client-specific breakage\u003C\u002Ftd>\n\u003Ctd>Litmus, Email on Acid, Testi@\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Accessibility\u003C\u002Ftd>\n\u003Ctd>Contrast, alt text, structure\u003C\u002Ftd>\n\u003Ctd>axe, manual screen reader pass\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Deliverability\u002Fspam\u003C\u002Ftd>\n\u003Ctd>Spam score, SPF\u002FDKIM\u002FDMARC\u003C\u002Ftd>\n\u003Ctd>mail-tester.com, GlockApps\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Link &amp; variable checks\u003C\u002Ftd>\n\u003Ctd>Dead links, missing data\u003C\u002Ftd>\n\u003Ctd>Automated tests in CI\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>Catch Rendering Bugs in CI\u003C\u002Fh3>\n\u003Cp>Treat email templates like application code. A simple CI step can compile every MJML template, fail the build on validation errors, and run snapshot tests on the rendered HTML so an accidental change is caught in review.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># Compile and validate all templates in CI; fail on any error\nmjml --validate strict templates\u002F*.mjml -o build\u002F\n\n# Spam-score a representative rendered email\n# (send to a mail-tester address, then assert the score via API)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Use Email Capture in Development\u003C\u002Fh3>\n\u003Cp>Tools like \u003Cstrong>Mailpit\u003C\u002Fstrong> or \u003Cstrong>Mailhog\u003C\u002Fstrong> run a local SMTP server and a web UI that catches every email your app sends in development. You see the HTML part, the text part, headers, and source without spamming real inboxes — ideal for verifying dynamic variables render correctly end to end.\u003C\u002Fp>\n\u003Ch3>Always Send a Real Test\u003C\u002Fh3>\n\u003Cp>Previews lie. Before shipping a new template, send it to real Gmail, Outlook, and Apple Mail accounts and open them on a phone. The five minutes this takes routinely catches dark-mode inversions and Outlook spacing bugs that no simulator surfaced.\u003C\u002Fp>\n\u003Ch2>Common Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even experienced teams repeat these errors with transactional email templates:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Using external or embedded CSS without inlining.\u003C\u002Fstrong> Gmail strips \u003Ccode>&lt;style&gt;\u003C\u002Fcode> in some contexts. Inline your critical styles (or use a build step that inlines them automatically).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Layouts built with \u003Ccode>div\u003C\u002Fcode> + flexbox\u002Fgrid.\u003C\u002Fstrong> They collapse in Outlook. Use tables for structure.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No plain-text part.\u003C\u002Fstrong> Hurts deliverability and accessibility; flagged by spam filters.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Image-only emails.\u003C\u002Fstrong> When images are blocked (a common default), the message is blank. Keep critical content — codes, links, CTAs — as live text.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Hard-coded widths over 600px.\u003C\u002Fstrong> Causes horizontal scroll on mobile and clipping in Outlook's reading pane.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring dark mode.\u003C\u002Fstrong> Dark text on a client-inverted dark background becomes invisible.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Unescaped user input.\u003C\u002Fstrong> An injection and rendering risk; always auto-escape untrusted variables.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No fallback fonts.\u003C\u002Fstrong> Web fonts often fail to load in email; always provide a system font stack like \u003Ccode>Arial, Helvetica, sans-serif\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping authentication.\u003C\u002Fstrong> SPF, DKIM, and DMARC are not optional. Without them, even a perfect template lands in spam.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Shipping without testing on real devices.\u003C\u002Fstrong> Simulators miss client-specific quirks.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>FAQ\u003C\u002Fh2>\n\u003Ch3>What are transactional email templates?\u003C\u002Fh3>\n\u003Cp>Transactional email templates are reusable HTML (and plain-text) layouts for automated, user-triggered messages such as password resets, receipts, email verifications, and shipping notifications. They contain dynamic placeholders that your application fills with per-message data — a name, a code, an order total — at send time. Unlike marketing templates, they are expected by the recipient and tied to a specific action.\u003C\u002Fp>\n\u003Ch3>Should transactional email templates use tables or div-based layouts?\u003C\u002Fh3>\n\u003Cp>Use tables for layout structure. Email clients like Outlook on Windows render with Microsoft Word's engine, which does not support \u003Ccode>flexbox\u003C\u002Fcode>, \u003Ccode>grid\u003C\u002Fcode>, or \u003Ccode>float\u003C\u002Fcode>. Tables with inline styles are the only reliable cross-client layout method. You can use \u003Ccode>div\u003C\u002Fcode>s for small inner elements, but the overall column structure should be table-based.\u003C\u002Fp>\n\u003Ch3>What is MJML and should I use it?\u003C\u002Fh3>\n\u003Cp>MJML is an open-source markup language that compiles a clean, semantic syntax into responsive, cross-client table HTML. It handles Outlook conditional comments, responsive math, and many rendering quirks automatically. For developer teams that want maintainable transactional email templates in version control, MJML (or React Email) is one of the best email template best practices available today.\u003C\u002Fp>\n\u003Ch3>Do I really need a plain-text version of every email?\u003C\u002Fh3>\n\u003Cp>Yes. A \u003Ccode>multipart\u002Falternative\u003C\u002Fcode> message with both HTML and plain-text parts improves deliverability (spam filters reward it), supports accessibility, and provides a fallback for clients and devices that prefer text. Write the text version deliberately rather than auto-stripping HTML tags, and keep its content in sync with the HTML.\u003C\u002Fp>\n\u003Ch3>How do I make transactional email templates work in dark mode?\u003C\u002Fh3>\n\u003Cp>Declare support with \u003Ccode>color-scheme\u003C\u002Fcode> and \u003Ccode>supported-color-schemes\u003C\u002Fcode> meta tags, add \u003Ccode>prefers-color-scheme: dark\u003C\u002Fcode> media queries for clients that honor them, and avoid pure black\u002Fwhite for large areas. Critically, test the \u003Cem>client-forced inversion\u003C\u002Fem> behavior too, since some clients ignore your media query and invert colors themselves — which can hide dark text or logos.\u003C\u002Fp>\n\u003Ch3>How should I handle dynamic variables securely?\u003C\u002Fh3>\n\u003Cp>Use a templating engine that auto-escapes HTML by default (Jinja2, Handlebars, Go's \u003Ccode>html\u002Ftemplate\u003C\u002Fcode>) so user-supplied values cannot inject markup. Provide default fallbacks for optional fields, format numbers, dates, and currency on the server in the user's locale, and validate that required variables exist before sending. Keep business logic out of templates — pass in a fully prepared view-model.\u003C\u002Fp>\n\u003Ch3>How do I test email templates across different clients?\u003C\u002Fh3>\n\u003Cp>Layer your testing: preview locally with the MJML CLI or a tool like Mailpit, validate HTML in CI, run a rendering matrix in Litmus or Email on Acid, check accessibility with axe and a screen reader, and score deliverability with mail-tester.com. Always finish by sending a real test to Gmail, Outlook, and Apple Mail and opening it on a phone.\u003C\u002Fp>\n\u003Ch3>Why do my emails land in spam even with a good template?\u003C\u002Fh3>\n\u003Cp>Template quality alone does not guarantee inbox placement. You also need proper authentication — SPF, DKIM, and DMARC records — a healthy sending domain reputation, a matching plain-text part, and a reputable sending infrastructure. A clean template helps spam scores, but deliverability is mostly a sending-reputation and authentication problem.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Transactional email templates are critical product surfaces, not throwaway HTML. The email template best practices that consistently pay off are the same ones top engineering teams enforce: build responsive, table-based HTML capped at 600px; adopt MJML or React Email so templates are maintainable and code-reviewed; always ship a plain-text alternative; design for accessibility and dark mode from the start; escape dynamic variables and format data on the server; and test systematically across real clients before every release.\u003C\u002Fp>\n\u003Cp>Do these well and your verification codes arrive readable, your receipts render in Outlook, and your reset buttons survive dark mode — which is exactly when users are paying the most attention.\u003C\u002Fp>\n\u003Ch2>Send Your Transactional Email With Postwing\u003C\u002Fh2>\n\u003Cp>Great templates still need infrastructure that delivers them. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa>\u003C\u002Fstrong> is a transactional email delivery platform built for developers and SaaS companies: a clean API, built-in SPF\u002FDKIM\u002FDMARC handling, plain-text and HTML multipart support, detailed delivery logs, and the deliverability reputation that gets your templates to the inbox.\u003C\u002Fp>\n\u003Cp>Postwing also accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, so you can pay for email infrastructure in stablecoin with no card, no chargebacks, and transparent on-chain billing — ideal for global teams and crypto-native startups.\u003C\u002Fp>\n\u003Cp>Ship your transactional email templates with confidence. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing today →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","1c925138-51fc-4065-98ad-a40bfc4bed54","2026-06-24T18:28:18.060985+03:00","2026-07-15T23:56:15.296617+03:00","Email Templates Best Practices for Developers","Master transactional email templates with developer-focused best practices: responsive HTML, MJML, dark mode, accessibility, dynamic variables, and testing.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_1c925138-51fc-4065-98ad-a40bfc4bed54\u002Femail-templates-best-practices.png","2026-07-28T09:00:00+03:00",[85,86],"transactional email templates","email template best practices",{"id":88,"body":89,"uuid":90,"created_at":91,"updated_at":92,"brand":13,"header":93,"short_body":94,"image":95,"published":17,"published_at":96,"tags":97},17,"\u003Cp>Email webhooks are HTTP callbacks that your email provider sends to your application the moment something happens to a message you dispatched — it was delivered, it bounced, the recipient opened it, or they marked it as spam. Instead of repeatedly asking the provider \"what happened to that email?\", your provider pushes each event to a URL you control, in near real time. For any SaaS product that depends on transactional email — password resets, receipts, verification codes — email webhooks are the only practical way to know whether your messages actually reached people.\u003C\u002Fp>\n\u003Cp>Here's the core problem they solve: when your code calls your provider's API and gets a \u003Ccode>200 OK\u003C\u002Fcode>, that response only confirms the message was \u003Cem>accepted for sending\u003C\u002Fem>. Everything that determines whether the user actually receives it — the recipient's mail server, spam filtering, greylisting, reputation checks — happens downstream, asynchronously, after your request has already returned. Delivery webhooks are how that downstream half of the email lifecycle reports back to you.\u003C\u002Fp>\n\u003Cp>This guide is a practical, developer-focused walkthrough of how email webhooks work: the event types you'll receive, how to set up and secure a webhook endpoint, how to verify signatures so nobody can forge events, and how to handle retries and idempotency so duplicate deliveries don't corrupt your data. There are working code examples you can adapt directly.\u003C\u002Fp>\n\u003Ch2>What Are Email Webhooks?\u003C\u002Fh2>\n\u003Cp>An email webhook is a user-defined HTTP callback. You register a URL with your email provider; whenever an event occurs for one of your messages, the provider makes an HTTP \u003Ccode>POST\u003C\u002Fcode> request to that URL with a JSON payload describing the event. Your application receives it, verifies it, and reacts.\u003C\u002Fp>\n\u003Cp>The contrast that makes webhooks valuable is \u003Cstrong>push vs. pull\u003C\u002Fstrong>:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Polling (pull):\u003C\u002Fstrong> your app periodically calls the provider's API asking \"any updates on these 10,000 messages?\" This is slow, wasteful, rate-limited, and always behind.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Webhooks (push):\u003C\u002Fstrong> the provider calls \u003Cem>you\u003C\u002Fem> the instant an event happens. No polling loop, no wasted requests, near-real-time data.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A Postwing webhook payload looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;event_id&quot;: &quot;a1b2c3d4-0001-4f3a-9c2e-7b6d5e4f3a21&quot;,\n  &quot;event&quot;: &quot;delivered&quot;,\n  &quot;message_id&quot;: &quot;&lt;2f1c8e90-...@yourdomain.com&gt;&quot;,\n  &quot;email&quot;: &quot;user@example.com&quot;,\n  &quot;timestamp&quot;: &quot;2026-06-24T09:41:13.482921+00:00&quot;,\n  &quot;data&quot;: {\n    &quot;smtp_code&quot;: 250,\n    &quot;mx_host&quot;: &quot;mx1.recipient-domain.com&quot;\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Every payload has the same top-level shape: a unique \u003Ccode>event_id\u003C\u002Fcode> (for idempotency — see below), the \u003Ccode>event\u003C\u002Fcode> type, the \u003Ccode>message_id\u003C\u002Fcode> of the original email, the recipient \u003Ccode>email\u003C\u002Fcode>, an ISO-8601 \u003Ccode>timestamp\u003C\u002Fcode>, and an event-specific \u003Ccode>data\u003C\u002Fcode> object (for example \u003Ccode>smtp_code\u003C\u002Fcode>\u002F\u003Ccode>mx_host\u003C\u002Fcode> on delivery events, or \u003Ccode>reason\u003C\u002Fcode> on a \u003Ccode>dropped\u003C\u002Fcode>\u002F\u003Ccode>unsubscribed\u003C\u002Fcode> event). Your handler reads \u003Ccode>event\u003C\u002Fcode>, ties it back to the original message via \u003Ccode>message_id\u003C\u002Fcode>, and updates state — mark the email delivered, suppress a bounced address, flag a complaint, and so on.\u003C\u002Fp>\n\u003Ch3>Email webhooks vs. delivery webhooks\u003C\u002Fh3>\n\u003Cp>These terms are often used interchangeably, but it's worth being precise. \u003Cstrong>Email webhooks\u003C\u002Fstrong> is the umbrella term for every event callback your provider can send, including engagement events like opens and clicks. \u003Cstrong>Delivery webhooks\u003C\u002Fstrong> refers specifically to the deliverability-related lifecycle events — \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, \u003Ccode>deferred\u003C\u002Fcode>, \u003Ccode>dropped\u003C\u002Fcode>, \u003Ccode>complained\u003C\u002Fcode> — that tell you whether the message reached the inbox. Delivery webhooks are the subset that matters most for transactional email, because for a password reset you care far more about \u003Cem>did it arrive\u003C\u002Fem> than \u003Cem>did they open it\u003C\u002Fem>.\u003C\u002Fp>\n\u003Ch2>Why Email Webhooks Matter for Transactional Email\u003C\u002Fh2>\n\u003Cp>Transactional emails sit on the critical path of your product: account verification, password resets, payment receipts, login codes. When one fails, the user is blocked, and you usually find out via a support ticket — or not at all.\u003C\u002Fp>\n\u003Cp>Without email webhooks, your visibility ends at the API response. With them, you regain control of the downstream half:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Catch failures fast.\u003C\u002Fstrong> A bounce or drop event arrives within seconds, so you can retry, alert, or fall back to SMS before the user gives up.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Protect your sender reputation.\u003C\u002Fstrong> Every \u003Ccode>bounced\u003C\u002Fcode> and \u003Ccode>complained\u003C\u002Fcode> event lets you auto-suppress that address. Repeatedly mailing bad addresses is the fastest way to tank deliverability.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Drive product logic.\u003C\u002Fstrong> \"Resend if not delivered within 5 minutes\", \"show a 'check your inbox' banner only after \u003Ccode>delivered\u003C\u002Fcode>\", or \"escalate to SMS on bounce\" all require live delivery data.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Feed monitoring and analytics.\u003C\u002Fstrong> Webhook events are the raw material for delivery rate, bounce rate, and complaint rate dashboards.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Industry deliverability guidance from sources like the \u003Ca href=\"https:\u002F\u002Fwww.m3aawg.org\u002F\">M3AAWG sender best practices\u003C\u002Fa> and ISP postmaster pages (Google, Microsoft) consistently emphasizes promptly processing bounces and complaints — and webhooks are the mechanism that makes that automatic rather than manual.\u003C\u002Fp>\n\u003Ch2>Email Delivery Event Types Explained\u003C\u002Fh2>\n\u003Cp>The lifecycle is universal, even if event names vary by provider. Postwing emits the following seven events, each mapping to a real transition in the email's lifecycle.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Event\u003C\u002Fth>\n\u003Cth>What it means\u003C\u002Fth>\n\u003Cth>Typical action\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>delivered\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Recipient's mail server accepted the message (\u003Ccode>250 OK\u003C\u002Fcode>)\u003C\u002Ftd>\n\u003Ctd>Mark delivered; this is your success signal\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>deferred\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Temporary failure; Postwing will retry (greylisting, throttling, 4xx)\u003C\u002Ftd>\n\u003Ctd>Wait; only worry if it persists\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>bounced\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Permanent failure (5xx \u002F no such mailbox); not delivered\u003C\u002Ftd>\n\u003Ctd>Suppress address; stop sending\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>complained\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Permanent failure attributed to a spam\u002Freputation\u002Fpolicy block\u003C\u002Ftd>\n\u003Ctd>Immediately suppress; never re-send\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>dropped\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Postwing didn't attempt send because the address is on your suppression list\u003C\u002Ftd>\n\u003Ctd>Investigate; check suppression list\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>opened\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Recipient opened the email (tracking pixel loaded)\u003C\u002Ftd>\n\u003Ctd>Engagement analytics only\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>unsubscribed\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Recipient used the unsubscribe mechanism\u003C\u002Ftd>\n\u003Ctd>Honor suppression\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Note that Postwing does not emit a \u003Ccode>clicked\u003C\u002Fcode> event — it tracks opens (a tracking pixel), not individual link clicks. Both \u003Ccode>bounced\u003C\u002Fcode> and \u003Ccode>complained\u003C\u002Fcode> are permanent delivery failures; Postwing splits them out so a reputation\u002Fpolicy block (which you should treat as a complaint signal) is distinguishable from an ordinary hard bounce.\u003C\u002Fp>\n\u003Cp>A few important nuances:\u003C\u002Fp>\n\u003Ch3>Hard bounce vs. soft bounce\u003C\u002Fh3>\n\u003Cp>A \u003Cstrong>hard bounce\u003C\u002Fstrong> is permanent — the address doesn't exist, the domain is invalid, or the server permanently rejects it. Suppress immediately. A \u003Cstrong>soft bounce\u003C\u002Fstrong> is temporary — mailbox full, server down, message too large. Providers usually retry soft bounces internally and only emit a \u003Ccode>bounced\u003C\u002Fcode> event once they give up. Treat a single soft bounce as noise and a pattern of them as a reputation warning.\u003C\u002Fp>\n\u003Ch3>Why \"opened\" is unreliable\u003C\u002Fh3>\n\u003Cp>The \u003Ccode>opened\u003C\u002Fcode> event fires when a tracking pixel (a 1×1 image) loads. But Apple Mail Privacy Protection, corporate image proxies, and privacy-focused clients pre-fetch or block these pixels. That means opens are \u003Cstrong>over-counted\u003C\u002Fstrong> for some recipients and \u003Cstrong>never recorded\u003C\u002Fstrong> for others. Use opens for rough engagement trends, never for deliverability decisions or per-user logic. For transactional email, \u003Ccode>delivered\u003C\u002Fcode> is the event that matters.\u003C\u002Fp>\n\u003Ch3>Complaints are the most dangerous event\u003C\u002Fh3>\n\u003Cp>A \u003Ccode>complained\u003C\u002Fcode> event means an ISP's feedback loop reported that the user hit \"spam\". Your complaint rate is one of the strongest reputation signals an ISP watches. Keep it below \u003Cstrong>0.1%\u003C\u002Fstrong>; a sustained rate above \u003Cstrong>0.3%\u003C\u002Fstrong> will get you throttled or blocked. Every complaint should trigger immediate, permanent suppression.\u003C\u002Fp>\n\u003Ch2>How to Set Up a Webhook Endpoint\u003C\u002Fh2>\n\u003Cp>Setting up email webhooks is a four-step process: build an endpoint, register it, verify incoming requests, and process events safely. Let's walk through each.\u003C\u002Fp>\n\u003Ch3>Step 1: Build the endpoint\u003C\u002Fh3>\n\u003Cp>Create an HTTPS route in your app that accepts \u003Ccode>POST\u003C\u002Fcode> requests and returns quickly. The golden rule: \u003Cstrong>acknowledge fast, process later.\u003C\u002Fstrong> Your handler should validate the request, enqueue the event for background processing, and immediately return \u003Ccode>200\u003C\u002Fcode>. If you do heavy work synchronously — database writes, downstream API calls — you risk timing out, which causes the provider to retry and double-deliver.\u003C\u002Fp>\n\u003Cp>Here's a minimal but production-shaped handler in Python (Flask). Note imports are at the top of the file:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hmac\nimport hashlib\nimport json\nimport os\nimport time\n\nfrom flask import Flask, request, abort, jsonify\n\napp = Flask(__name__)\n\nWEBHOOK_SECRET = os.environ[&quot;POSTWING_WEBHOOK_SECRET&quot;].encode()\nTOLERANCE_SECONDS = 300  # reject events older than 5 minutes\n\n\ndef verify_signature(payload: bytes, timestamp: str, signature: str) -&gt; bool:\n    # Reject stale requests to prevent replay attacks.\n    try:\n        if abs(time.time() - int(timestamp)) &gt; TOLERANCE_SECONDS:\n            return False\n    except (TypeError, ValueError):\n        return False\n\n    signed_content = timestamp.encode() + b&quot;.&quot; + payload\n    expected = hmac.new(WEBHOOK_SECRET, signed_content, hashlib.sha256).hexdigest()\n    # Constant-time comparison prevents timing attacks.\n    return hmac.compare_digest(expected, signature)\n\n\n@app.route(&quot;\u002Fwebhooks\u002Femail&quot;, methods=[&quot;POST&quot;])\ndef email_webhook():\n    payload = request.get_data()  # raw bytes — needed for signature check\n    timestamp = request.headers.get(&quot;X-Webhook-Timestamp&quot;, &quot;&quot;)\n    signature = request.headers.get(&quot;X-Webhook-Signature&quot;, &quot;&quot;)\n\n    if not verify_signature(payload, timestamp, signature):\n        abort(401)\n\n    event = json.loads(payload)\n\n    # Acknowledge immediately, then process in the background.\n    enqueue_event(event)\n    return jsonify({&quot;status&quot;: &quot;ok&quot;}), 200\n\n\ndef enqueue_event(event: dict) -&gt; None:\n    # Push to a queue (Celery, RQ, SQS, etc.) for async processing.\n    # Keep this fast; do the real work in a worker.\n    ...\n\n\nif __name__ == &quot;__main__&quot;:\n    app.run(port=8080)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 2: Register the URL with your provider\u003C\u002Fh3>\n\u003Cp>In your provider's dashboard or API, add your endpoint URL (e.g. \u003Ccode>https:\u002F\u002Fapi.yourapp.com\u002Fwebhooks\u002Femail\u003C\u002Fcode>) and select which events you want to receive. Subscribe only to events you actually use — there's no reason to receive \u003Ccode>opened\u003C\u002Fcode> if you're only acting on deliverability. During development, use a tunneling tool like \u003Ca href=\"https:\u002F\u002Fngrok.com\u002F\">ngrok\u003C\u002Fa> to expose your local endpoint so you can test against real provider events.\u003C\u002Fp>\n\u003Ch3>Step 3: Verify signatures (covered next)\u003C\u002Fh3>\n\u003Cp>Never trust an unauthenticated webhook. See the section below.\u003C\u002Fp>\n\u003Ch3>Step 4: Process events asynchronously\u003C\u002Fh3>\n\u003Cp>Your background worker consumes the queued event and applies your business logic — update message status, suppress addresses, increment metrics. This keeps your HTTP handler fast and decouples acknowledgment from processing.\u003C\u002Fp>\n\u003Ch2>Securing Email Webhooks: Signature Verification\u003C\u002Fh2>\n\u003Cp>Your webhook endpoint is a public URL. Anyone who discovers it can \u003Ccode>POST\u003C\u002Fcode> fake events — forging a \u003Ccode>delivered\u003C\u002Fcode> for a message that bounced, or injecting bogus complaint events. \u003Cstrong>Signature verification\u003C\u002Fstrong> is how you confirm that each request genuinely came from your provider and wasn't tampered with in transit.\u003C\u002Fp>\n\u003Ch3>How signature verification works\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Your provider and you share a \u003Cstrong>signing secret\u003C\u002Fstrong> (issued when you create the webhook).\u003C\u002Fli>\n\u003Cli>For each request, the provider computes an \u003Cstrong>HMAC\u003C\u002Fstrong> (usually SHA-256) over the request body — often combined with a timestamp — using that secret.\u003C\u002Fli>\n\u003Cli>The provider sends the resulting signature in a header (e.g. \u003Ccode>X-Webhook-Signature\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>Your endpoint recomputes the HMAC over the \u003Cstrong>raw\u003C\u002Fstrong> received body with the same secret and compares.\u003C\u002Fli>\n\u003Cli>If they match, the request is authentic and untampered. If not, reject with \u003Ccode>401\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Every Postwing webhook request carries these headers:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Header\u003C\u002Fth>\n\u003Cth>Purpose\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Signature\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Hex HMAC-SHA256 of \u003Ccode>\"{timestamp}.{raw_body}\"\u003C\u002Fcode>, keyed by your endpoint secret\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Timestamp\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Unix epoch seconds when the request was signed (use for replay protection)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Event\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>The event type (e.g. \u003Ccode>delivered\u003C\u002Fcode>), so you can route without parsing the body\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Delivery\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>The unique delivery\u002Fevent id — identical to \u003Ccode>event_id\u003C\u002Fcode> in the body, for idempotency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The signing secret is shown when you create the endpoint (and again on retrieve); rotate it any time and Postwing signs with the new value immediately. The \u003Ccode>verify_signature\u003C\u002Fcode> function in the Flask example above shows the full pattern. Three details are critical:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Use the raw body.\u003C\u002Fstrong> Compute the HMAC over the exact bytes received, \u003Cem>before\u003C\u002Fem> JSON parsing. Re-serializing the parsed JSON changes whitespace\u002Fkey order and breaks the signature.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Use constant-time comparison.\u003C\u002Fstrong> \u003Ccode>hmac.compare_digest\u003C\u002Fcode> (not \u003Ccode>==\u003C\u002Fcode>) prevents timing attacks that could leak the correct signature byte by byte.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Include a timestamp tolerance.\u003C\u002Fstrong> Reject requests whose timestamp is older than a few minutes. This stops \u003Cstrong>replay attacks\u003C\u002Fstrong>, where an attacker captures a valid signed request and re-sends it later.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Node.js \u002F Express example\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-javascript\">const crypto = require(&quot;crypto&quot;);\nconst express = require(&quot;express&quot;);\n\nconst app = express();\nconst SECRET = process.env.POSTWING_WEBHOOK_SECRET;\nconst TOLERANCE = 300; \u002F\u002F seconds\n\n\u002F\u002F Capture the raw body for signature verification.\napp.use(&quot;\u002Fwebhooks\u002Femail&quot;, express.raw({ type: &quot;application\u002Fjson&quot; }));\n\nfunction verify(rawBody, timestamp, signature) {\n  if (Math.abs(Date.now() \u002F 1000 - Number(timestamp)) &gt; TOLERANCE) return false;\n  const signed = `${timestamp}.${rawBody}`;\n  const expected = crypto\n    .createHmac(&quot;sha256&quot;, SECRET)\n    .update(signed)\n    .digest(&quot;hex&quot;);\n  const a = Buffer.from(expected);\n  const b = Buffer.from(signature || &quot;&quot;);\n  return a.length === b.length &amp;&amp; crypto.timingSafeEqual(a, b);\n}\n\napp.post(&quot;\u002Fwebhooks\u002Femail&quot;, (req, res) =&gt; {\n  const timestamp = req.header(&quot;X-Webhook-Timestamp&quot;);\n  const signature = req.header(&quot;X-Webhook-Signature&quot;);\n\n  if (!verify(req.body, timestamp, signature)) {\n    return res.status(401).send(&quot;invalid signature&quot;);\n  }\n\n  const event = JSON.parse(req.body.toString());\n  enqueueEvent(event); \u002F\u002F async processing\n  res.status(200).json({ status: &quot;ok&quot; });\n});\n\napp.listen(8080);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Defense in depth\u003C\u002Fh3>\n\u003Cp>Signature verification is the primary control, but layer on more where you can:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>HTTPS only\u003C\u002Fstrong> — never accept webhooks over plain HTTP.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>IP allowlisting\u003C\u002Fstrong> — if your provider publishes static source IP ranges, restrict your endpoint to them.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A hard-to-guess URL path\u003C\u002Fstrong> — include a random token segment so the endpoint isn't trivially discoverable.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Retries and Idempotency\u003C\u002Fh2>\n\u003Cp>Webhook delivery is best-effort over an unreliable network, so two things are guaranteed to happen eventually: your endpoint will be \u003Cstrong>unavailable\u003C\u002Fstrong> sometimes, and you will receive the \u003Cstrong>same event more than once\u003C\u002Fstrong>. Handling both correctly is what separates a toy integration from a reliable one.\u003C\u002Fp>\n\u003Ch3>How retries work\u003C\u002Fh3>\n\u003Cp>If your endpoint doesn't respond with a \u003Ccode>2xx\u003C\u002Fcode> status within the timeout (your server is down, slow, or returns an error), the provider treats the delivery as failed and \u003Cstrong>retries\u003C\u002Fstrong> later with increasing backoff. Postwing's schedule is \u003Cstrong>1 min, 5 min, 30 min, 2 h, 6 h, 24 h\u003C\u002Fstrong>; after the last retry the delivery is marked permanently failed and is no longer attempted (you can inspect failed deliveries via the API or dashboard). The per-request timeout is 10 seconds.\u003C\u002Fp>\n\u003Cp>Implications for your handler:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Return \u003Ccode>200\u003C\u002Fcode> only when you've safely accepted the event\u003C\u002Fstrong> (enqueued or persisted). If you return \u003Ccode>200\u003C\u002Fcode> and then crash before saving, that event is lost forever — the provider considers it delivered.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Return a non-\u003Ccode>2xx\u003C\u002Fcode> to trigger a retry\u003C\u002Fstrong> when you genuinely can't process the event yet (e.g. your queue is down). A \u003Ccode>500\u003C\u002Fcode> or \u003Ccode>503\u003C\u002Fcode> tells the provider to try again.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Respond fast.\u003C\u002Fstrong> Slow responses look like failures and cause retries even when nothing is wrong. This is why you acknowledge first and process async.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Why idempotency is non-negotiable\u003C\u002Fh3>\n\u003Cp>Because of retries, you \u003Cstrong>will\u003C\u002Fstrong> receive duplicate events. If your handler isn't idempotent, a retried \u003Ccode>bounced\u003C\u002Fcode> event could double-count your bounce metric, a duplicate \u003Ccode>complained\u003C\u002Fcode> could fire two suppression emails to your team, and a re-delivered billing-related event could trigger duplicate side effects.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Idempotency\u003C\u002Fstrong> means processing the same event twice produces the same result as processing it once. The standard implementation uses a unique event ID:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import json\n\nfrom flask import Flask, request, abort, jsonify\n\napp = Flask(__name__)\n\n\ndef already_processed(event_id: str) -&gt; bool:\n    # Atomically record the event id; return True if it existed already.\n    # Redis: SET event:&lt;id&gt; 1 NX EX 604800  -&gt; returns None if key exists.\n    # SQL:   INSERT ... ON CONFLICT DO NOTHING; check affected rows.\n    ...\n\n\ndef handle_event(event: dict) -&gt; None:\n    event_type = event[&quot;event&quot;]\n    if event_type == &quot;bounced&quot;:\n        suppress_address(event[&quot;email&quot;], reason=&quot;bounce&quot;)\n    elif event_type == &quot;complained&quot;:\n        suppress_address(event[&quot;email&quot;], reason=&quot;complaint&quot;)\n    elif event_type == &quot;delivered&quot;:\n        mark_delivered(event[&quot;message_id&quot;])\n\n\ndef process_webhook(event: dict) -&gt; None:\n    event_id = event[&quot;event_id&quot;]  # provider-supplied unique id\n\n    if already_processed(event_id):\n        return  # duplicate — safely ignore\n\n    handle_event(event)\n\n\ndef suppress_address(email: str, reason: str) -&gt; None:\n    ...\n\n\ndef mark_delivered(message_id: str) -&gt; None:\n    ...\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The key is making \u003Ccode>already_processed\u003C\u002Fcode> \u003Cstrong>atomic\u003C\u002Fstrong> — a single database \u003Ccode>INSERT ... ON CONFLICT DO NOTHING\u003C\u002Fcode> or a Redis \u003Ccode>SET ... NX\u003C\u002Fcode> — so two concurrent duplicate deliveries can't both pass the check. Store processed event IDs with a TTL (7 days covers most retry windows).\u003C\u002Fp>\n\u003Ch3>Handling out-of-order events\u003C\u002Fh3>\n\u003Cp>Retries also mean events can arrive \u003Cstrong>out of order\u003C\u002Fstrong> — a \u003Ccode>deferred\u003C\u002Fcode> retried after the later \u003Ccode>delivered\u003C\u002Fcode> already landed. Don't assume strict ordering. Make state transitions monotonic: once a message is marked delivered, a late \u003Ccode>deferred\u003C\u002Fcode> event shouldn't downgrade it. Where order matters, key your logic off the event's own \u003Ccode>timestamp\u003C\u002Fcode>, not arrival time.\u003C\u002Fp>\n\u003Ch2>Common Email Webhook Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even teams that wire up email webhooks often undermine them. Watch for these.\u003C\u002Fp>\n\u003Ch3>1. Skipping signature verification\u003C\u002Fh3>\n\u003Cp>The most common — and most dangerous — mistake. An unverified endpoint is a public API that anyone can feed fake events. Always verify the HMAC signature before trusting a single field.\u003C\u002Fp>\n\u003Ch3>2. Doing heavy work synchronously\u003C\u002Fh3>\n\u003Cp>If your handler writes to multiple tables and calls downstream APIs before returning \u003Ccode>200\u003C\u002Fcode>, it will be slow, time out, and get retried — multiplying the very work that made it slow. Acknowledge first, process in a worker.\u003C\u002Fp>\n\u003Ch3>3. Parsing the body before verifying\u003C\u002Fh3>\n\u003Cp>Frameworks that auto-parse JSON discard the raw bytes you need for the HMAC. Capture the raw body first (\u003Ccode>express.raw\u003C\u002Fcode>, \u003Ccode>request.get_data()\u003C\u002Fcode>), verify, \u003Cem>then\u003C\u002Fem> parse.\u003C\u002Fp>\n\u003Ch3>4. Not handling duplicates\u003C\u002Fh3>\n\u003Cp>Without idempotency, retries silently corrupt your metrics and trigger duplicate side effects. Dedupe on the provider's \u003Ccode>event_id\u003C\u002Fcode> with an atomic check.\u003C\u002Fp>\n\u003Ch3>5. Returning \u003Ccode>200\u003C\u002Fcode> on failure\u003C\u002Fh3>\n\u003Cp>If your queue is down, returning \u003Ccode>200\u003C\u002Fcode> tells the provider \"got it\" and the event is gone forever. Return \u003Ccode>5xx\u003C\u002Fcode> so it retries; return \u003Ccode>200\u003C\u002Fcode> only after you've durably accepted the event.\u003C\u002Fp>\n\u003Ch3>6. Trusting \u003Ccode>opened\u003C\u002Fcode> for delivery decisions\u003C\u002Fh3>\n\u003Cp>Opens are inflated by some clients and invisible from others (Apple MPP, image proxies). Never gate transactional logic on \u003Ccode>opened\u003C\u002Fcode>. Use \u003Ccode>delivered\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>7. Not auto-suppressing bounces and complaints\u003C\u002Fh3>\n\u003Cp>If a \u003Ccode>bounced\u003C\u002Fcode> or \u003Ccode>complained\u003C\u002Fcode> event doesn't immediately add the address to a suppression list, you'll keep mailing bad addresses and destroy your reputation. Automate it in the handler.\u003C\u002Fp>\n\u003Ch3>8. No replay protection\u003C\u002Fh3>\n\u003Cp>Without a timestamp tolerance, a captured valid request can be replayed indefinitely. Reject events older than a few minutes.\u003C\u002Fp>\n\u003Ch2>Webhooks vs. Polling: A Quick Comparison\u003C\u002Fh2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Aspect\u003C\u002Fth>\n\u003Cth>Email webhooks (push)\u003C\u002Fth>\n\u003Cth>Polling (pull)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Latency\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Near real-time (seconds)\u003C\u002Ftd>\n\u003Ctd>As slow as your poll interval\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Efficiency\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Provider calls you only on events\u003C\u002Ftd>\n\u003Ctd>Constant requests, mostly empty\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Scalability\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Scales with event volume\u003C\u002Ftd>\n\u003Ctd>Scales with message count × frequency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Rate limits\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Not an issue\u003C\u002Ftd>\n\u003Ctd>You'll hit them at scale\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Setup complexity\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Endpoint + verification + idempotency\u003C\u002Ftd>\n\u003Ctd>Just an API loop\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Reliability burden\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>You must handle retries\u002Fduplicates\u003C\u002Ftd>\n\u003Ctd>Provider handles consistency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>For anything beyond a hobby project, webhooks win decisively. Polling only makes sense as a fallback to reconcile events you might have missed during downtime — pull recent events via the API to backfill, then resume relying on the push stream.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>What are email webhooks?\u003C\u002Fh3>\n\u003Cp>Email webhooks are HTTP callbacks your email provider sends to a URL you control whenever something happens to a message — it was delivered, bounced, opened, or marked as spam. Instead of polling the provider's API for status, you receive each event in near real time as a JSON \u003Ccode>POST\u003C\u002Fcode> request. They're the standard way to track transactional email delivery and react to failures automatically.\u003C\u002Fp>\n\u003Ch3>What is the difference between email webhooks and delivery webhooks?\u003C\u002Fh3>\n\u003Cp>\"Email webhooks\" is the general term for all event callbacks, including engagement events like opens and clicks. \"Delivery webhooks\" specifically refers to deliverability lifecycle events — \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, \u003Ccode>deferred\u003C\u002Fcode>, \u003Ccode>dropped\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode> — that tell you whether a message reached the inbox. For transactional email, delivery webhooks are the subset that matters most.\u003C\u002Fp>\n\u003Ch3>How do I verify an email webhook is authentic?\u003C\u002Fh3>\n\u003Cp>Verify the signature. Your provider signs each request with an HMAC (usually SHA-256) computed over the raw request body using a shared secret, and sends the result in a header. On your side, recompute the HMAC over the exact raw bytes received with the same secret and compare using a constant-time function like \u003Ccode>hmac.compare_digest\u003C\u002Fcode>. If they don't match, reject the request with \u003Ccode>401\u003C\u002Fcode>. Also enforce a timestamp tolerance to block replay attacks.\u003C\u002Fp>\n\u003Ch3>What email events can webhooks track?\u003C\u002Fh3>\n\u003Cp>Postwing sends \u003Ccode>delivered\u003C\u002Fcode> (accepted by the recipient server), \u003Ccode>deferred\u003C\u002Fcode> (temporary failure, will retry), \u003Ccode>bounced\u003C\u002Fcode> (permanent failure), \u003Ccode>complained\u003C\u002Fcode> (permanent failure from a spam\u002Freputation\u002Fpolicy block), \u003Ccode>dropped\u003C\u002Fcode> (not attempted because the address is suppressed), \u003Ccode>opened\u003C\u002Fcode> (tracking pixel loaded), and \u003Ccode>unsubscribed\u003C\u002Fcode>. The deliverability events — delivered, bounced, deferred, complained — are the most important for transactional mail. (Postwing tracks opens but not link clicks, so there is no \u003Ccode>clicked\u003C\u002Fcode> event.)\u003C\u002Fp>\n\u003Ch3>How should I handle webhook retries and duplicates?\u003C\u002Fh3>\n\u003Cp>Assume you'll receive every event more than once. Make your handler idempotent by deduplicating on the provider's unique \u003Ccode>event_id\u003C\u002Fcode> using an atomic operation (a SQL \u003Ccode>INSERT ... ON CONFLICT DO NOTHING\u003C\u002Fcode> or Redis \u003Ccode>SET ... NX\u003C\u002Fcode>) before processing. Return \u003Ccode>200\u003C\u002Fcode> only after you've durably accepted the event; return a \u003Ccode>5xx\u003C\u002Fcode> to trigger a retry if you can't process it yet. Store processed IDs with a TTL covering the provider's retry window (typically 7 days).\u003C\u002Fp>\n\u003Ch3>Why is my webhook endpoint receiving duplicate events?\u003C\u002Fh3>\n\u003Cp>Because webhook delivery is best-effort. If your endpoint is slow, errors, or times out, the provider can't tell whether you received the event, so it retries — sometimes after you actually did process it. Network glitches and provider-side reties also cause duplicates. This is expected behavior; the fix is idempotent processing, not trying to eliminate duplicates.\u003C\u002Fp>\n\u003Ch3>Should I trust the \"opened\" event for tracking delivery?\u003C\u002Fh3>\n\u003Cp>No. The \u003Ccode>opened\u003C\u002Fcode> event relies on a tracking pixel that's blocked or pre-fetched by Apple Mail Privacy Protection, corporate image proxies, and privacy-focused clients, so opens are simultaneously over-counted and under-counted. Use opens for rough engagement trends only. For knowing whether a transactional email reached the user, rely on the \u003Ccode>delivered\u003C\u002Fcode> event.\u003C\u002Fp>\n\u003Ch3>Do I need webhooks if my provider has a dashboard?\u003C\u002Fh3>\n\u003Cp>A dashboard shows you aggregate trends, but it can't drive your application logic. Webhooks let your code react automatically — suppress a bounced address, resend on failure, escalate to SMS, or update a message's status in your own database. If you need real-time, per-message reactions (and for transactional email you do), you need webhooks, not just a dashboard.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Email webhooks close the visibility gap between \"the provider accepted my message\" and \"the user actually received it.\" That second half of the lifecycle — delivery, bounces, deferrals, complaints — happens downstream and asynchronously, invisible to your application unless you're listening. Webhooks are how you listen.\u003C\u002Fp>\n\u003Cp>The pattern is consistent regardless of provider: build a fast HTTPS endpoint, \u003Cstrong>verify every signature\u003C\u002Fstrong> with a constant-time HMAC check and a timestamp tolerance, \u003Cstrong>acknowledge immediately\u003C\u002Fstrong> and process events in a background worker, and make that processing \u003Cstrong>idempotent\u003C\u002Fstrong> so retries and duplicates never corrupt your data. Get those four things right and you have a webhook integration that's secure, reliable, and ready to drive real product logic — auto-suppressing bad addresses, resending failed messages, and feeding your monitoring dashboards.\u003C\u002Fp>\n\u003Cp>Start this week: subscribe to \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode>, verify signatures, dedupe on \u003Ccode>event_id\u003C\u002Fcode>, and auto-suppress bounces and complaints. That alone puts you ahead of most SaaS products and protects your most critical user flows from failing in silence.\u003C\u002Fp>\n\u003Ch2>Track Email Delivery Events with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers and SaaS companies, with email webhooks as a first-class feature. You get signed webhook events for every lifecycle stage — \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>deferred\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, \u003Ccode>complained\u003C\u002Fcode>, \u003Ccode>dropped\u003C\u002Fcode>, \u003Ccode>opened\u003C\u002Fcode>, and \u003Ccode>unsubscribed\u003C\u002Fcode> — each with an HMAC-SHA256 signature for verification, a unique \u003Ccode>event_id\u003C\u002Fcode> for idempotency, and automatic retries with backoff so you never silently lose events.\u003C\u002Fp>\n\u003Cp>Bounces and complaints feed automatic suppression, so your reputation stays protected without manual list hygiene, and built-in delivery, bounce, and complaint dashboards turn your webhook stream into the metrics that matter. Because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, international founders and crypto-native teams can pay without card requirements or traditional payment-rail friction.\u003C\u002Fp>\n\u003Cp>Stop guessing whether your emails arrived. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start tracking email delivery events with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","608112e6-49bf-4c8e-9013-f2d3a0b64cd9","2026-06-24T18:28:18.055777+03:00","2026-07-15T23:56:58.573364+03:00","Webhooks Explained: Tracking Email Delivery Events","Email webhooks push delivery, bounce, open, and complaint events to your app in real time. Learn endpoint setup, signature verification, and retries with code.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_608112e6-49bf-4c8e-9013-f2d3a0b64cd9\u002Fwebhooks-explained.png","2026-07-24T09:00:00+03:00",[98,99],"email webhooks","delivery webhooks",{"id":101,"body":102,"uuid":103,"created_at":104,"updated_at":105,"brand":13,"header":106,"short_body":107,"image":108,"published":17,"published_at":109,"tags":110},16,"\u003Cp>A password reset email is the single most time-sensitive message your application will ever send. When a locked-out user clicks \"Forgot password,\" they are stuck on a screen, watching their inbox, and counting the seconds. If that email lands late, lands in spam, or never arrives at all, you don't just lose a session — you lose trust, support tickets pile up, and churn climbs. Yet most engineering teams treat the password reset email as an afterthought: a token, a link, and a \u003Ccode>send()\u003C\u002Fcode> call wired up in an afternoon.\u003C\u002Fp>\n\u003Cp>This guide treats it as what it actually is — a critical-path authentication email that has to be secure, fast, and inbox-reliable. We'll walk the full flow end to end: generating and expiring tokens the right way, writing reset content that converts and survives spam filters, configuring SPF, DKIM, and DMARC so your message authenticates, and shipping production-ready code you can adapt today. By the end you'll have a password reset flow that actually delivers, both in the security sense and the deliverability sense.\u003C\u002Fp>\n\u003Ch2>Why the Password Reset Email Is Harder Than It Looks\u003C\u002Fh2>\n\u003Cp>The deceptively simple \"send a link\" flow hides three independent failure domains, and a weakness in any one of them breaks the whole experience.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Security:\u003C\u002Fstrong> The reset link is a bearer credential. Anyone who holds it can take over the account. Weak tokens, no expiry, or a leaky enumeration endpoint turn your reset flow into an attack surface.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Deliverability:\u003C\u002Fstrong> Reset emails are transactional and urgent, but mailbox providers don't know that automatically. Without proper authentication, your message gets throttled, filtered to spam, or silently dropped.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>User experience:\u003C\u002Fstrong> Latency, confusing copy, broken links on mobile, and tokens that expire before the user reads the email all generate support load and abandonment.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A 2023 Cloudflare analysis of authentication traffic found that account-recovery flows are among the most-targeted endpoints for credential-stuffing and enumeration attacks, precisely because they bridge an unauthenticated user to an authenticated session. Meanwhile, Google's Postmaster Tools guidelines make clear that unauthenticated bulk and transactional mail is increasingly likely to be rejected outright. The password reset email sits squarely at the intersection of these two pressures, which is why it deserves real engineering attention rather than a copy-pasted snippet.\u003C\u002Fp>\n\u003Ch2>Anatomy of a Secure Password Reset Flow\u003C\u002Fh2>\n\u003Cp>Before writing any code, agree on the sequence. A robust flow looks like this:\u003C\u002Fp>\n\u003Col>\n\u003Cli>User submits their email address on the \"forgot password\" form.\u003C\u002Fli>\n\u003Cli>Server looks up the account \u003Cstrong>without revealing whether it exists\u003C\u002Fstrong> (more on enumeration below).\u003C\u002Fli>\n\u003Cli>If the account exists, the server generates a cryptographically random token, stores a \u003Cstrong>hash\u003C\u002Fstrong> of it with an expiry timestamp, and invalidates any prior tokens.\u003C\u002Fli>\n\u003Cli>The server sends a password reset email containing a link with the raw token.\u003C\u002Fli>\n\u003Cli>User clicks the link; the server hashes the supplied token, looks it up, checks expiry and single-use status.\u003C\u002Fli>\n\u003Cli>User sets a new password; the server consumes the token, updates the password hash, and optionally invalidates active sessions.\u003C\u002Fli>\n\u003Cli>The server sends a confirmation email (\"your password was changed\").\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Each numbered step maps to a concrete control. Skip one and you've created a gap.\u003C\u002Fp>\n\u003Ch3>Step 1–2: Look Up the User Without Leaking Existence\u003C\u002Fh3>\n\u003Cp>The classic mistake is returning \"No account found with that email.\" That message is a free gift to attackers performing \u003Cstrong>user enumeration\u003C\u002Fstrong> — they now know exactly which addresses are registered. The fix is to always return the same neutral response:\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\"If an account exists for that email, we've sent reset instructions.\"\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>Send the email only when the account exists, but never branch your HTTP response on it. Keep response timing roughly constant too, so attackers can't infer existence from latency.\u003C\u002Fp>\n\u003Ch2>Secure Token Generation and Expiry\u003C\u002Fh2>\n\u003Cp>The token is the heart of the password reset email. Get this wrong and nothing else matters.\u003C\u002Fp>\n\u003Ch3>Use a Cryptographically Secure Random Token\u003C\u002Fh3>\n\u003Cp>Do \u003Cstrong>not\u003C\u002Fstrong> use \u003Ccode>uuid4()\u003C\u002Fcode> as your only source of entropy if your platform's UUID isn't backed by a CSPRNG, and never use predictable values like \u003Ccode>user_id + timestamp\u003C\u002Fcode>. Use a dedicated secure random generator with at least 128 bits (32 hex chars) — 256 bits is a comfortable default.\u003C\u002Fp>\n\u003Ch3>Store a Hash, Not the Raw Token\u003C\u002Fh3>\n\u003Cp>Treat the reset token like a password. If your database leaks, an attacker with raw tokens can reset accounts directly. Store a SHA-256 hash of the token and compare hashes on lookup. The raw token lives only in the email.\u003C\u002Fp>\n\u003Ch3>Set a Short Expiry and Make Tokens Single-Use\u003C\u002Fh3>\n\u003Cp>Industry consensus (OWASP Forgot Password Cheat Sheet) is a \u003Cstrong>short lifetime\u003C\u002Fstrong> — 15 minutes to 1 hour. Long enough for a user to check email, short enough to limit exposure. Each token must be:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Single-use:\u003C\u002Fstrong> consumed the moment a password is set.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Invalidated on reissue:\u003C\u002Fstrong> requesting a new link kills the old one.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Expired on success:\u003C\u002Fstrong> changing the password (or logging in) voids outstanding tokens.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Here is a production-ready Python example. Imports go at the top, per good practice.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hashlib\nimport secrets\nfrom datetime import datetime, timedelta, timezone\nfrom dataclasses import dataclass\n\nTOKEN_TTL = timedelta(minutes=30)\n\n\n@dataclass\nclass ResetToken:\n    user_id: int\n    token_hash: str\n    expires_at: datetime\n    used: bool = False\n\n\ndef generate_reset_token() -&gt; tuple[str, str]:\n    &quot;&quot;&quot;Return (raw_token_for_email, token_hash_for_db).&quot;&quot;&quot;\n    raw_token = secrets.token_urlsafe(32)  # ~256 bits of entropy\n    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()\n    return raw_token, token_hash\n\n\ndef create_reset_record(user_id: int) -&gt; tuple[str, ResetToken]:\n    raw_token, token_hash = generate_reset_token()\n    record = ResetToken(\n        user_id=user_id,\n        token_hash=token_hash,\n        expires_at=datetime.now(timezone.utc) + TOKEN_TTL,\n    )\n    # Invalidate prior tokens for this user, then persist `record`.\n    return raw_token, record\n\n\ndef verify_reset_token(raw_token: str, record: ResetToken) -&gt; bool:\n    candidate_hash = hashlib.sha256(raw_token.encode()).hexdigest()\n    if record.used:\n        return False\n    if datetime.now(timezone.utc) &gt; record.expires_at:\n        return False\n    return secrets.compare_digest(candidate_hash, record.token_hash)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Note the use of \u003Ccode>secrets.compare_digest\u003C\u002Fcode> for a constant-time comparison, which prevents timing attacks against the hash lookup.\u003C\u002Fp>\n\u003Ch3>Token Strategy Comparison\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Approach\u003C\u002Fth>\n\u003Cth>Entropy\u003C\u002Fth>\n\u003Cth>Stored as\u003C\u002Fth>\n\u003Cth>Revocable\u003C\u002Fth>\n\u003Cth>Verdict\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>user_id\u003C\u002Fcode> + timestamp\u003C\u002Ftd>\n\u003Ctd>Near zero\u003C\u002Ftd>\n\u003Ctd>Plaintext\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003Ctd>Never use\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>uuid4()\u003C\u002Fcode> only\u003C\u002Ftd>\n\u003Ctd>~122 bits\u003C\u002Ftd>\n\u003Ctd>Plaintext\u003C\u002Ftd>\n\u003Ctd>Manual\u003C\u002Ftd>\n\u003Ctd>Weak — predictable libs, stored raw\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>secrets.token_urlsafe(32)\u003C\u002Fcode> + SHA-256 hash\u003C\u002Ftd>\n\u003Ctd>~256 bits\u003C\u002Ftd>\n\u003Ctd>Hash\u003C\u002Ftd>\n\u003Ctd>Yes\u003C\u002Ftd>\n\u003Ctd>Recommended\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Signed JWT (no DB record)\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Self-contained\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Hard to revoke\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Avoid — can't enforce single-use\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>JWTs are tempting because they're stateless, but a stateless token can't be marked \"used\" without a server-side record — which defeats the point. Stick with a random token plus a hashed database row.\u003C\u002Fp>\n\u003Ch2>Password Reset Email Content Best Practices\u003C\u002Fh2>\n\u003Cp>A secure token is useless if the user never acts on the email. The content of a password reset email has to be instantly recognizable, trustworthy, and frictionless.\u003C\u002Fp>\n\u003Ch3>Subject Line and Sender\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Subject:\u003C\u002Fstrong> Be direct and specific. \"Reset your Postwing password\" beats clever copy. Avoid spammy words (\"URGENT!!!\", \"Click here NOW\").\u003C\u002Fli>\n\u003Cli>\u003Cstrong>From name:\u003C\u002Fstrong> Use a recognizable brand sender, e.g. \u003Ccode>Postwing &lt;security@postwing.app&gt;\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>From a subdomain\u003C\u002Fstrong> dedicated to transactional mail (e.g. \u003Ccode>mail.yourdomain.com\u003C\u002Fcode>) so reputation stays separate from marketing sends.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Body Content That Builds Trust\u003C\u002Fh3>\n\u003Cp>A good reset email is short and answers four questions immediately:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>What is this?\u003C\u002Fstrong> \"We received a request to reset your password.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>What do I do?\u003C\u002Fstrong> A single, prominent button: \"Reset Password.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>How long do I have?\u003C\u002Fstrong> \"This link expires in 30 minutes.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>What if it wasn't me?\u003C\u002Fstrong> \"If you didn't request this, you can safely ignore this email.\"\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Include the raw URL as plain text alongside the button — some clients strip or break buttons, and security-conscious users want to see where the link points.\u003C\u002Fp>\n\u003Ch3>A Clean HTML Template\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;!DOCTYPE html&gt;\n&lt;html lang=&quot;en&quot;&gt;\n&lt;body style=&quot;margin:0;padding:0;background:#f4f4f5;font-family:Arial,sans-serif;&quot;&gt;\n  &lt;table role=&quot;presentation&quot; width=&quot;100%&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot;&gt;\n    &lt;tr&gt;\n      &lt;td align=&quot;center&quot; style=&quot;padding:32px 16px;&quot;&gt;\n        &lt;table role=&quot;presentation&quot; width=&quot;480&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot;\n               style=&quot;background:#ffffff;border-radius:8px;padding:32px;&quot;&gt;\n          &lt;tr&gt;&lt;td&gt;\n            &lt;h1 style=&quot;font-size:20px;margin:0 0 16px;&quot;&gt;Reset your password&lt;\u002Fh1&gt;\n            &lt;p style=&quot;font-size:15px;line-height:1.6;color:#3f3f46;&quot;&gt;\n              We received a request to reset the password for your account.\n              Click the button below to choose a new password.\n            &lt;\u002Fp&gt;\n            &lt;p style=&quot;text-align:center;margin:28px 0;&quot;&gt;\n              &lt;a href=&quot;https:\u002F\u002Fapp.example.com\u002Freset?token=RAW_TOKEN&quot;\n                 style=&quot;background:#16a34a;color:#ffffff;text-decoration:none;\n                        padding:12px 28px;border-radius:6px;font-size:15px;\n                        display:inline-block;&quot;&gt;\n                Reset Password\n              &lt;\u002Fa&gt;\n            &lt;\u002Fp&gt;\n            &lt;p style=&quot;font-size:13px;color:#71717a;line-height:1.6;&quot;&gt;\n              This link expires in 30 minutes. If you didn't request a reset,\n              you can safely ignore this email — your password won't change.\n            &lt;\u002Fp&gt;\n            &lt;p style=&quot;font-size:12px;color:#a1a1aa;word-break:break-all;&quot;&gt;\n              Or paste this link into your browser:\n              https:\u002F\u002Fapp.example.com\u002Freset?token=RAW_TOKEN\n            &lt;\u002Fp&gt;\n          &lt;\u002Ftd&gt;&lt;\u002Ftr&gt;\n        &lt;\u002Ftable&gt;\n      &lt;\u002Ftd&gt;\n    &lt;\u002Ftr&gt;\n  &lt;\u002Ftable&gt;\n&lt;\u002Fbody&gt;\n&lt;\u002Fhtml&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Content Rules That Affect Deliverability\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Always send a plain-text alternative\u003C\u002Fstrong> alongside HTML (a multipart\u002Falternative message). HTML-only emails score worse with spam filters.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep the image-to-text ratio low.\u003C\u002Fstrong> A reset email that is one big image looks like a phishing attempt to filters.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Avoid URL shorteners.\u003C\u002Fstrong> Use your own authenticated domain in links; shorteners hurt reputation and look like cloaking.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Match the link domain to your sending domain\u003C\u002Fstrong> where possible — mismatches trigger phishing heuristics.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Deliverability: SPF, DKIM, and DMARC for Authentication Email\u003C\u002Fh2>\n\u003Cp>You can write the perfect password reset email and still have it land in spam if the mailbox provider can't verify you sent it. Authentication is non-negotiable for any authentication email — especially after Google and Yahoo's 2024 bulk-sender requirements made SPF, DKIM, and DMARC effectively mandatory for reliable inbox placement.\u003C\u002Fp>\n\u003Ch3>The Three Records Explained\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Standard\u003C\u002Fth>\n\u003Cth>What it proves\u003C\u002Fth>\n\u003Cth>Where it lives\u003C\u002Fth>\n\u003Cth>Failure symptom\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>SPF\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>The sending server's IP is authorized for the domain\u003C\u002Ftd>\n\u003Ctd>TXT record on the domain\u003C\u002Ftd>\n\u003Ctd>\"via\" warnings, soft-fail to spam\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>DKIM\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>The message wasn't altered and came from the domain\u003C\u002Ftd>\n\u003Ctd>TXT record + signature in headers\u003C\u002Ftd>\n\u003Ctd>Tampering flags, spam folder\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>DMARC\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Tells receivers what to do when SPF\u002FDKIM fail, and aligns the From domain\u003C\u002Ftd>\n\u003Ctd>TXT record at \u003Ccode>_dmarc.yourdomain\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Outright rejection under \u003Ccode>p=reject\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>SPF: Authorize Your Senders\u003C\u002Fh3>\n\u003Cp>SPF is a DNS TXT record listing which servers may send mail for your domain. If you send through a provider, you include their mechanism:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>v=spf1 include:_spf.postwing.app ~all\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Keep SPF under the 10-DNS-lookup limit, and use \u003Ccode>~all\u003C\u002Fcode> (soft fail) or \u003Ccode>-all\u003C\u002Fcode> (hard fail) — never \u003Ccode>+all\u003C\u002Fcode>, which authorizes everyone.\u003C\u002Fp>\n\u003Ch3>DKIM: Sign Every Message\u003C\u002Fh3>\n\u003Cp>DKIM adds a cryptographic signature to each email's headers using a private key, with the public key published in DNS. Receivers verify the signature to confirm the message is authentic and unmodified. Your email provider gives you a public key to publish as a TXT record, typically at a selector like \u003Ccode>pw1._domainkey.yourdomain.com\u003C\u002Fcode>. Once published, every outgoing password reset email is signed automatically.\u003C\u002Fp>\n\u003Ch3>DMARC: Set Policy and Get Reports\u003C\u002Fh3>\n\u003Cp>DMARC ties SPF and DKIM together with \u003Cstrong>alignment\u003C\u002Fstrong> (the authenticated domain must match your visible From domain) and tells receivers how to handle failures:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com; adkim=s; aspf=s; pct=100\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Start with \u003Ccode>p=none\u003C\u002Fcode> to monitor via the aggregate reports (\u003Ccode>rua\u003C\u002Fcode>), confirm your legitimate mail passes alignment, then progress to \u003Ccode>p=quarantine\u003C\u002Fcode> and finally \u003Ccode>p=reject\u003C\u002Fcode>. Moving to \u003Ccode>p=reject\u003C\u002Fcode> too early can blackhole your own reset emails.\u003C\u002Fp>\n\u003Ch3>A Realistic Rollout Order\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Publish \u003Cstrong>SPF\u003C\u002Fstrong> and verify legitimate mail passes.\u003C\u002Fli>\n\u003Cli>Enable \u003Cstrong>DKIM\u003C\u002Fstrong> signing at your provider and publish the public key.\u003C\u002Fli>\n\u003Cli>Publish \u003Cstrong>DMARC\u003C\u002Fstrong> at \u003Ccode>p=none\u003C\u002Fcode> and read aggregate reports for a week or two.\u003C\u002Fli>\n\u003Cli>Confirm both SPF and DKIM \u003Cstrong>align\u003C\u002Fstrong> with your From domain.\u003C\u002Fli>\n\u003Cli>Tighten DMARC to \u003Ccode>p=quarantine\u003C\u002Fcode>, then \u003Ccode>p=reject\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Warm a \u003Cstrong>dedicated subdomain\u003C\u002Fstrong> for transactional mail to keep reset-email reputation clean.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>This is exactly the kind of setup a purpose-built transactional provider handles for you. Postwing, for example, walks you through publishing SPF and DKIM records for your domain and signs every transactional message automatically, so your password reset emails authenticate from day one.\u003C\u002Fp>\n\u003Ch2>Sending the Email: A Production Example\u003C\u002Fh2>\n\u003Cp>With tokens and DNS in place, the actual send should be a thin, reliable call. Below is a complete example using a transactional API. Imports at the top.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import os\nimport smtplib\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\n\nSMTP_HOST = os.environ[&quot;SMTP_HOST&quot;]\nSMTP_PORT = int(os.environ.get(&quot;SMTP_PORT&quot;, &quot;587&quot;))\nSMTP_USER = os.environ[&quot;SMTP_USER&quot;]\nSMTP_PASS = os.environ[&quot;SMTP_PASS&quot;]\nFROM_ADDR = &quot;Postwing &lt;security@mail.example.com&gt;&quot;\n\n\ndef build_reset_email(to_addr: str, reset_url: str) -&gt; MIMEMultipart:\n    msg = MIMEMultipart(&quot;alternative&quot;)\n    msg[&quot;Subject&quot;] = &quot;Reset your password&quot;\n    msg[&quot;From&quot;] = FROM_ADDR\n    msg[&quot;To&quot;] = to_addr\n\n    text = (\n        &quot;We received a request to reset your password.\\n\\n&quot;\n        f&quot;Reset it here (expires in 30 minutes):\\n{reset_url}\\n\\n&quot;\n        &quot;If you didn't request this, ignore this email.&quot;\n    )\n    html = f&quot;&quot;&quot;\\\n    &lt;p&gt;We received a request to reset your password.&lt;\u002Fp&gt;\n    &lt;p&gt;&lt;a href=&quot;{reset_url}&quot;&gt;Reset your password&lt;\u002Fa&gt; (expires in 30 minutes).&lt;\u002Fp&gt;\n    &lt;p&gt;If you didn't request this, you can safely ignore this email.&lt;\u002Fp&gt;\n    &quot;&quot;&quot;\n\n    msg.attach(MIMEText(text, &quot;plain&quot;))\n    msg.attach(MIMEText(html, &quot;html&quot;))\n    return msg\n\n\ndef send_reset_email(to_addr: str, reset_url: str) -&gt; None:\n    msg = build_reset_email(to_addr, reset_url)\n    with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:\n        server.starttls()\n        server.login(SMTP_USER, SMTP_PASS)\n        server.send_message(msg)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>In production, push the send onto a background queue so the HTTP request returns immediately, and add retry logic with exponential backoff for transient failures.\u003C\u002Fp>\n\u003Ch3>Don't Forget the Confirmation Email\u003C\u002Fh3>\n\u003Cp>After a successful reset, send a second authentication email: \"Your password was changed.\" If the change wasn't authorized by the real user, this is their early-warning signal to contact support. It's a small touch that materially improves account security and trust.\u003C\u002Fp>\n\u003Ch2>Common Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even experienced teams trip over the same issues. Watch for these:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Revealing whether an email is registered.\u003C\u002Fstrong> Branching the response on account existence enables enumeration. Always return a neutral message.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No token expiry or expiry measured in days.\u003C\u002Fstrong> A 7-day-valid reset link is a 7-day-long account-takeover window. Cap it at 15–60 minutes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Storing raw tokens in the database.\u003C\u002Fstrong> A DB leak then becomes instant account compromise. Store hashes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Reusable tokens.\u003C\u002Fstrong> If a token isn't consumed on use, a leaked email thread or browser-history entry stays exploitable. Make it single-use.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Not invalidating sessions on reset.\u003C\u002Fstrong> If an attacker had a session, resetting the password should log them out. Rotate session tokens.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending without SPF\u002FDKIM\u002FDMARC.\u003C\u002Fstrong> The fastest way to land critical mail in spam. Authenticate before you scale.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mixing transactional and marketing on one domain.\u003C\u002Fstrong> A marketing spam complaint can tank the reputation that delivers your reset emails. Use a dedicated subdomain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No rate limiting.\u003C\u002Fstrong> Unlimited reset requests let attackers spam a victim's inbox and probe for valid accounts. Throttle per-account and per-IP.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocking the request thread on the SMTP call.\u003C\u002Fstrong> Send asynchronously so users aren't staring at a spinner.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Putting the token in a URL that gets logged.\u003C\u002Fstrong> Reset tokens in query strings can leak via server logs, referrer headers, and analytics. Strip them from logs and set a strict referrer policy.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>How long should a password reset email link stay valid?\u003C\u002Fh3>\n\u003Cp>Between 15 minutes and 1 hour is the industry standard (per the OWASP Forgot Password Cheat Sheet). This is long enough for a user to find the email and act, but short enough to limit the window in which a leaked link can be abused. Always make the token single-use as well, so it stops working the moment the password is changed.\u003C\u002Fp>\n\u003Ch3>Why does my password reset email go to spam?\u003C\u002Fh3>\n\u003Cp>The most common cause is missing or misaligned email authentication. Without correct SPF, DKIM, and DMARC records, mailbox providers can't verify you're a legitimate sender and route the message to spam — or reject it. Other causes include sending from a domain with poor reputation, HTML-only emails with no plain-text part, image-heavy content, and link domains that don't match your sending domain.\u003C\u002Fp>\n\u003Ch3>Should I use a JWT for the password reset token?\u003C\u002Fh3>\n\u003Cp>Generally no. A JWT is stateless, which means you can't easily mark it as \"used\" or revoke it without a server-side record — and single-use enforcement is essential for a reset token. A cryptographically random token stored as a SHA-256 hash in a database row, with an expiry and a \u003Ccode>used\u003C\u002Fcode> flag, gives you revocation, single-use, and reissue control that a bare JWT can't.\u003C\u002Fp>\n\u003Ch3>How do I prevent user enumeration in the reset flow?\u003C\u002Fh3>\n\u003Cp>Never reveal whether an email address is registered. Return the same neutral response (\"If an account exists, we've sent reset instructions\") whether or not the account exists, keep response timing roughly constant, and rate-limit requests per email and per IP address so attackers can't probe at scale.\u003C\u002Fp>\n\u003Ch3>Do I need a dedicated domain or subdomain for transactional email?\u003C\u002Fh3>\n\u003Cp>It's strongly recommended. Sending password reset and other authentication emails from a dedicated subdomain (e.g. \u003Ccode>mail.yourdomain.com\u003C\u002Fcode>) isolates their sender reputation from marketing campaigns. If a marketing send triggers spam complaints, your critical transactional mail still delivers because it's authenticated under a separate, clean reputation.\u003C\u002Fp>\n\u003Ch3>What's the difference between SPF, DKIM, and DMARC?\u003C\u002Fh3>\n\u003Cp>SPF authorizes which servers may send mail for your domain. DKIM cryptographically signs each message so receivers can confirm it wasn't altered and genuinely came from your domain. DMARC ties the two together with alignment rules and tells receivers what to do when checks fail (do nothing, quarantine, or reject), plus sends you reports. You need all three for reliable delivery of authentication email.\u003C\u002Fp>\n\u003Ch3>Should I invalidate active sessions when a password is reset?\u003C\u002Fh3>\n\u003Cp>Yes. If a user is resetting their password because their account was compromised, leaving existing sessions active lets the attacker stay logged in. On a successful reset, rotate or revoke all active sessions and send a confirmation email so the legitimate user knows the change happened.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>A password reset email looks trivial until you map its three failure domains — security, deliverability, and user experience — and realize each one can break the flow on its own. Do it properly and the recipe is clear:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Generate \u003Cstrong>256-bit random tokens\u003C\u002Fstrong>, store them \u003Cstrong>hashed\u003C\u002Fstrong>, expire them in \u003Cstrong>15–60 minutes\u003C\u002Fstrong>, and make them \u003Cstrong>single-use\u003C\u002Fstrong>.\u003C\u002Fli>\n\u003Cli>Write \u003Cstrong>short, scannable, multipart\u003C\u002Fstrong> email content with one clear action and an expiry notice.\u003C\u002Fli>\n\u003Cli>Authenticate every message with \u003Cstrong>SPF, DKIM, and DMARC\u003C\u002Fstrong>, rolling out DMARC from \u003Ccode>p=none\u003C\u002Fcode> to \u003Ccode>p=reject\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Send from a \u003Cstrong>dedicated transactional subdomain\u003C\u002Fstrong>, asynchronously, with rate limiting and a confirmation email.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Get these right and your reset flow becomes invisible in the best way: users get locked out, get their email in seconds, click once, and move on. That reliability is the difference between a recovery flow that quietly works and a churn-generating support burden.\u003C\u002Fp>\n\u003Ch2>Build Reset Emails That Land — With Postwing\u003C\u002Fh2>\n\u003Cp>Securing the token is your job; getting the email into the inbox is ours. \u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email delivery platform built for developers and SaaS teams who need authentication emails — password resets, verifications, confirmations — to arrive fast and authenticate cleanly. We guide you through SPF and DKIM setup, sign every message, and give you the deliverability tooling to keep your reset emails out of spam.\u003C\u002Fp>\n\u003Cp>And because we accept \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can start sending without a corporate card, a credit check, or a billing department in the loop — pay on-chain and ship. Connect your domain, drop in your reset template, and send your first authenticated password reset email today at \u003Ca href=\"https:\u002F\u002Fpostwing.app\">postwing.app\u003C\u002Fa>.\u003C\u002Fp>","6da7c71d-411a-4be7-88f0-52f8f3e2bba9","2026-06-24T18:28:18.049318+03:00","2026-07-15T23:48:41.059833+03:00","Building a Password Reset Flow That Actually Delivers","Build a password reset email flow that delivers: secure token generation, SPF\u002FDKIM\u002FDMARC setup, content best practices, and production-ready code examples.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_6da7c71d-411a-4be7-88f0-52f8f3e2bba9\u002FChatGPT_Image_Jul_15_2026_11_48_18_PM.png","2026-07-22T09:00:00+03:00",[111,112],"password reset email","authentication email",{"id":114,"body":115,"uuid":116,"created_at":117,"updated_at":118,"brand":13,"header":119,"short_body":120,"image":121,"published":17,"published_at":122,"tags":123},15,"\u003Cp>If you're building a SaaS product, an API, or any backend service in Python, sooner or later you need to send mail — password resets, receipts, OTP codes, signup confirmations, alerts. The fastest, most reliable way to do that today is with a \u003Cstrong>python email api\u003C\u002Fstrong>: an HTTP-based service you call with a single authenticated request, instead of wiring up raw SMTP sockets yourself. This guide is a hands-on, copy-paste-ready tutorial for sending \u003Cstrong>transactional email in Python\u003C\u002Fstrong> the right way — with proper configuration, error handling, retries, idempotency, and webhook processing.\u003C\u002Fp>\n\u003Cp>We'll write real, working code using both \u003Ccode>requests\u003C\u002Fcode> and the modern async \u003Ccode>httpx\u003C\u002Fcode> client, store secrets correctly, handle failures the way production systems should, and process delivery webhooks so your app knows what actually happened to each message. Whether you're a SaaS founder shipping your MVP, an engineer hardening an existing service, or a CTO standardizing how your team sends mail, this article gives you patterns you can put into production today.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Quick answer:\u003C\u002Fstrong> To send transactional email in Python, call a transactional \u003Cstrong>email API over HTTPS\u003C\u002Fstrong> using a client like \u003Ccode>requests\u003C\u002Fcode> or \u003Ccode>httpx\u003C\u002Fcode>. Load your API key from an environment variable, \u003Ccode>POST\u003C\u002Fcode> a JSON payload (\u003Ccode>from\u003C\u002Fcode>, \u003Ccode>to\u003C\u002Fcode>, \u003Ccode>subject\u003C\u002Fcode>, \u003Ccode>html\u003C\u002Fcode>), check the response status, retry transient \u003Ccode>5xx\u003C\u002Fcode>\u002Fnetwork errors with exponential backoff, and use an idempotency key so retries never double-send. Process delivery events via webhooks instead of polling.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>Why Use a Python Email API Instead of SMTP?\u003C\u002Fh2>\n\u003Cp>Python ships with \u003Ccode>smtplib\u003C\u002Fcode> in its standard library, so the obvious question is: why not just use that? You \u003Cem>can\u003C\u002Fem> send mail with \u003Ccode>smtplib\u003C\u002Fcode>, but for application-generated transactional email, an HTTP \u003Cstrong>python email api\u003C\u002Fstrong> is the better default for several concrete reasons.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Less code, fewer moving parts.\u003C\u002Fstrong> SMTP is a stateful, multi-step protocol (\u003Ccode>HELO\u003C\u002Fcode>, \u003Ccode>MAIL FROM\u003C\u002Fcode>, \u003Ccode>RCPT TO\u003C\u002Fcode>, \u003Ccode>DATA\u003C\u002Fcode>, \u003Ccode>QUIT\u003C\u002Fcode>). An email API is one \u003Ccode>POST\u003C\u002Fcode> request.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Firewall-friendly.\u003C\u002Fstrong> Most cloud providers block outbound port 25, and 587\u002F465 are frequently throttled. An HTTP API uses port 443, which is essentially never blocked.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Rich, synchronous feedback.\u003C\u002Fstrong> An API returns a message ID and queue status in the response body. SMTP only tells you whether the relay \u003Cem>accepted\u003C\u002Fem> the handshake.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Webhooks for delivery events.\u003C\u002Fstrong> Delivered, bounced, opened, complained — pushed to your endpoint in near real time instead of you parsing bounce mailboxes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Built-in reliability features.\u003C\u002Fstrong> Idempotency keys, suppression lists, scheduling, and per-message tracking are first-class in an API and absent from raw SMTP.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For a deeper comparison, the short version is: \u003Cstrong>SMTP is a protocol you talk to; an email API is a service you call.\u003C\u002Fstrong> For Python apps sending transactional mail, the service wins on speed, observability, and developer ergonomics.\u003C\u002Fp>\n\u003Ch3>When \u003Ccode>smtplib\u003C\u002Fcode> Still Makes Sense\u003C\u002Fh3>\n\u003Cp>There are narrow cases where the standard library is fine: a quick internal script, a self-hosted relay you fully control, or integrating legacy software that only speaks SMTP. But for any user-facing, deliverability-sensitive transactional email in a Python SaaS backend, reach for an HTTP API.\u003C\u002Fp>\n\u003Ch2>What You Need Before Sending\u003C\u002Fh2>\n\u003Cp>Before the first line of code, get these prerequisites in place:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>A transactional email provider account and API key.\u003C\u002Fstrong> This tutorial uses a Postwing-style HTTP API; the patterns translate to any modern provider.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A verified sending domain with SPF, DKIM, and DMARC configured.\u003C\u002Fstrong> Without domain authentication, your mail lands in spam regardless of how clean your code is. Following Google and Yahoo's 2024 bulk-sender requirements, SPF + DKIM + DMARC are effectively mandatory for inbox placement.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Python 3.9+\u003C\u002Fstrong> and a way to manage dependencies (\u003Ccode>pip\u003C\u002Fcode>, \u003Ccode>poetry\u003C\u002Fcode>, or \u003Ccode>uv\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A secrets strategy.\u003C\u002Fstrong> Environment variables at minimum; a secrets manager (AWS Secrets Manager, Vault, Doppler) for production.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Install the HTTP clients we'll use:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">pip install requests httpx python-dotenv tenacity\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Configuration: Load Secrets the Right Way\u003C\u002Fh2>\n\u003Cp>Never hardcode an API key in source. The single most common security incident in email integrations is a key committed to a repo. Store it in an environment variable and load it at startup.\u003C\u002Fp>\n\u003Cp>Create a \u003Ccode>.env\u003C\u002Fcode> file (and add it to \u003Ccode>.gitignore\u003C\u002Fcode>):\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># .env  — never commit this file\nPOSTWING_API_KEY=pw_live_xxxxxxxxxxxxxxxxxxxx\nPOSTWING_API_BASE=https:\u002F\u002Fapi.postwing.app\nMAIL_FROM=&quot;Acme &lt;no-reply@acme.com&gt;&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Then load configuration once, in a small, importable module:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># config.py\nimport os\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nAPI_KEY = os.environ[&quot;POSTWING_API_KEY&quot;]\nAPI_BASE = os.environ.get(&quot;POSTWING_API_BASE&quot;, &quot;https:\u002F\u002Fapi.postwing.app&quot;)\nMAIL_FROM = os.environ.get(&quot;MAIL_FROM&quot;, &quot;Acme &lt;no-reply@acme.com&gt;&quot;)\n\nif not API_KEY:\n    raise RuntimeError(&quot;POSTWING_API_KEY is not set&quot;)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Reading the key with \u003Ccode>os.environ[\"...\"]\u003C\u002Fcode> (not \u003Ccode>.get()\u003C\u002Fcode>) makes the app \u003Cstrong>fail fast and loud\u003C\u002Fstrong> at startup if the secret is missing, rather than silently sending nothing in production.\u003C\u002Fp>\n\u003Ch2>Sending Your First Transactional Email in Python\u003C\u002Fh2>\n\u003Cp>Here's the minimal end-to-end example: a synchronous send using \u003Ccode>requests\u003C\u002Fcode>. This is the simplest working \u003Cstrong>transactional email Python\u003C\u002Fstrong> snippet you can build on.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># send_basic.py\nimport requests\nfrom config import API_KEY, API_BASE, MAIL_FROM\n\n\ndef send_email(to: str, subject: str, html: str) -&gt; dict:\n    response = requests.post(\n        f&quot;{API_BASE}\u002Fv1\u002Femails&quot;,\n        headers={\n            &quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;,\n            &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n        },\n        json={\n            &quot;from&quot;: MAIL_FROM,\n            &quot;to&quot;: to,\n            &quot;subject&quot;: subject,\n            &quot;html&quot;: html,\n        },\n        timeout=10,\n    )\n    response.raise_for_status()\n    return response.json()\n\n\nif __name__ == &quot;__main__&quot;:\n    result = send_email(\n        to=&quot;user@example.com&quot;,\n        subject=&quot;Welcome to Acme&quot;,\n        html=&quot;&lt;h1&gt;Welcome!&lt;\u002Fh1&gt;&lt;p&gt;Thanks for signing up.&lt;\u002Fp&gt;&quot;,\n    )\n    print(&quot;Sent:&quot;, result[&quot;id&quot;])\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Three details make this production-ready rather than a toy:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>\u003Ccode>timeout=10\u003C\u002Fcode>\u003C\u002Fstrong> — never make a network call without a timeout. A hung request can stall a web worker indefinitely.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\u003Ccode>raise_for_status()\u003C\u002Fcode>\u003C\u002Fstrong> — turns \u003Ccode>4xx\u003C\u002Fcode>\u002F\u003Ccode>5xx\u003C\u002Fcode> responses into exceptions instead of silently returning an error body you forget to check.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>The returned \u003Ccode>id\u003C\u002Fcode>\u003C\u002Fstrong> — store this message ID. You'll correlate it with webhook delivery events later.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Adding Plain-Text and Reply-To\u003C\u002Fh3>\n\u003Cp>Always send a plain-text alternative alongside HTML. Some clients prefer it, and a missing text part can hurt deliverability and accessibility. A realistic payload looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">payload = {\n    &quot;from&quot;: MAIL_FROM,\n    &quot;to&quot;: &quot;user@example.com&quot;,\n    &quot;reply_to&quot;: &quot;support@acme.com&quot;,\n    &quot;subject&quot;: &quot;Your receipt #1042&quot;,\n    &quot;html&quot;: &quot;&lt;p&gt;Thanks for your purchase.&lt;\u002Fp&gt;&quot;,\n    &quot;text&quot;: &quot;Thanks for your purchase.&quot;,\n    &quot;tags&quot;: [&quot;receipt&quot;],\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Building a Reusable Email Client\u003C\u002Fh2>\n\u003Cp>Calling \u003Ccode>requests.post\u003C\u002Fcode> ad hoc all over your codebase is a maintenance trap. Wrap the provider in a small client class. A \u003Ccode>requests.Session\u003C\u002Fcode> reuses the underlying TCP connection across calls, which meaningfully reduces latency when you send many messages.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># email_client.py\nimport requests\nfrom config import API_KEY, API_BASE, MAIL_FROM\n\n\nclass EmailError(Exception):\n    &quot;&quot;&quot;Raised when the email provider returns an error.&quot;&quot;&quot;\n\n    def __init__(self, status: int, body: str):\n        self.status = status\n        self.body = body\n        super().__init__(f&quot;Email API error {status}: {body}&quot;)\n\n\nclass EmailClient:\n    def __init__(self, api_key: str = API_KEY, base_url: str = API_BASE):\n        self.base_url = base_url.rstrip(&quot;\u002F&quot;)\n        self.session = requests.Session()\n        self.session.headers.update(\n            {\n                &quot;Authorization&quot;: f&quot;Bearer {api_key}&quot;,\n                &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n            }\n        )\n\n    def send(\n        self,\n        to: str,\n        subject: str,\n        html: str,\n        text: str | None = None,\n        sender: str = MAIL_FROM,\n        idempotency_key: str | None = None,\n    ) -&gt; dict:\n        headers = {}\n        if idempotency_key:\n            headers[&quot;Idempotency-Key&quot;] = idempotency_key\n\n        payload = {\n            &quot;from&quot;: sender,\n            &quot;to&quot;: to,\n            &quot;subject&quot;: subject,\n            &quot;html&quot;: html,\n        }\n        if text:\n            payload[&quot;text&quot;] = text\n\n        response = self.session.post(\n            f&quot;{self.base_url}\u002Fv1\u002Femails&quot;,\n            json=payload,\n            headers=headers,\n            timeout=10,\n        )\n\n        if response.status_code &gt;= 400:\n            raise EmailError(response.status_code, response.text)\n\n        return response.json()\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Now sending mail anywhere in your app is one clean call:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">from email_client import EmailClient\n\nclient = EmailClient()\nclient.send(\n    to=&quot;user@example.com&quot;,\n    subject=&quot;Reset your password&quot;,\n    html=&quot;&lt;p&gt;Click &lt;a href='https:\u002F\u002Facme.com\u002Freset?t=abc'&gt;here&lt;\u002Fa&gt; to reset.&lt;\u002Fp&gt;&quot;,\n    text=&quot;Reset your password: https:\u002F\u002Facme.com\u002Freset?t=abc&quot;,\n)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Error Handling: Distinguish Retryable from Fatal\u003C\u002Fh2>\n\u003Cp>Not all errors are equal. The crucial skill in production email code is telling apart errors you should \u003Cstrong>retry\u003C\u002Fstrong> from errors you must \u003Cstrong>not\u003C\u002Fstrong> retry.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Status \u002F condition\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Retry?\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>200\u003C\u002Fcode> \u002F \u003Ccode>201\u003C\u002Fcode> \u002F \u003Ccode>202\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Accepted for delivery\u003C\u002Ftd>\n\u003Ctd>No — done\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>400\u003C\u002Fcode> Bad Request\u003C\u002Ftd>\n\u003Ctd>Malformed payload (your bug)\u003C\u002Ftd>\n\u003Ctd>No — fix the code\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>401\u003C\u002Fcode> \u002F \u003Ccode>403\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Bad or revoked API key\u003C\u002Ftd>\n\u003Ctd>No — fix the config\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>422\u003C\u002Fcode> Unprocessable\u003C\u002Ftd>\n\u003Ctd>Invalid recipient \u002F suppressed address\u003C\u002Ftd>\n\u003Ctd>No — handle in app logic\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>429\u003C\u002Fcode> Too Many Requests\u003C\u002Ftd>\n\u003Ctd>Rate limited\u003C\u002Ftd>\n\u003Ctd>Yes — back off, honor \u003Ccode>Retry-After\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>500\u003C\u002Fcode> \u002F \u003Ccode>502\u003C\u002Fcode> \u002F \u003Ccode>503\u003C\u002Fcode> \u002F \u003Ccode>504\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Provider-side transient failure\u003C\u002Ftd>\n\u003Ctd>Yes — exponential backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Network timeout \u002F \u003Ccode>ConnectionError\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Transient\u003C\u002Ftd>\n\u003Ctd>Yes — exponential backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Retrying a \u003Ccode>400\u003C\u002Fcode> or \u003Ccode>422\u003C\u002Fcode> forever just hammers the provider and never succeeds. Retrying a \u003Ccode>500\u003C\u002Fcode> or a timeout is exactly right, because the next attempt may go through.\u003C\u002Fp>\n\u003Ch2>Retries with Exponential Backoff\u003C\u002Fh2>\n\u003Cp>Network blips and transient provider errors are inevitable at scale. Add bounded retries with exponential backoff and jitter. The \u003Ccode>tenacity\u003C\u002Fcode> library makes this clean and declarative.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># resilient_client.py\nimport requests\nimport tenacity\nfrom email_client import EmailClient, EmailError\n\n\ndef _is_retryable(exc: BaseException) -&gt; bool:\n    if isinstance(exc, (requests.Timeout, requests.ConnectionError)):\n        return True\n    if isinstance(exc, EmailError):\n        return exc.status == 429 or exc.status &gt;= 500\n    return False\n\n\nclass ResilientEmailClient(EmailClient):\n    @tenacity.retry(\n        retry=tenacity.retry_if_exception(_is_retryable),\n        wait=tenacity.wait_exponential_jitter(initial=0.5, max=30),\n        stop=tenacity.stop_after_attempt(5),\n        reraise=True,\n    )\n    def send(self, *args, **kwargs) -&gt; dict:\n        return super().send(*args, **kwargs)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This retries up to five times, only for transient failures, with exponentially increasing waits plus random jitter (so a fleet of workers doesn't retry in lockstep and create a thundering herd). Fatal errors like \u003Ccode>400\u003C\u002Fcode> or \u003Ccode>422\u003C\u002Fcode> are re-raised immediately.\u003C\u002Fp>\n\u003Ch3>Why You Need Idempotency with Retries\u003C\u002Fh3>\n\u003Cp>Retries introduce a subtle danger: if a request \u003Cem>succeeded\u003C\u002Fem> but the response was lost to a timeout, a naive retry sends the email twice. Sending a receipt or password reset twice is a real, user-visible bug.\u003C\u002Fp>\n\u003Cp>The fix is an \u003Cstrong>idempotency key\u003C\u002Fstrong> — a unique token per logical send. The provider deduplicates: if it already processed that key, it returns the original result instead of sending again.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import uuid\nfrom resilient_client import ResilientEmailClient\n\nclient = ResilientEmailClient()\n\n# One stable key per logical message — reuse it across retries.\nkey = str(uuid.uuid4())\n\nclient.send(\n    to=&quot;user@example.com&quot;,\n    subject=&quot;Your receipt #1042&quot;,\n    html=&quot;&lt;p&gt;Thanks for your purchase.&lt;\u002Fp&gt;&quot;,\n    text=&quot;Thanks for your purchase.&quot;,\n    idempotency_key=key,\n)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Generate the key \u003Cstrong>once\u003C\u002Fstrong> per logical email (e.g., derived from the order ID), not per HTTP attempt — that's the whole point. A good pattern is \u003Ccode>f\"receipt-{order_id}\"\u003C\u002Fcode> so the same order can never be billed-emailed twice even across process restarts.\u003C\u002Fp>\n\u003Ch2>Async Sending with httpx\u003C\u002Fh2>\n\u003Cp>If your service is async (FastAPI, async workers, asyncio task queues), use \u003Ccode>httpx.AsyncClient\u003C\u002Fcode> so email sends don't block the event loop. The API shape mirrors \u003Ccode>requests\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># async_client.py\nimport httpx\nfrom config import API_KEY, API_BASE, MAIL_FROM\n\n\nclass AsyncEmailClient:\n    def __init__(self, api_key: str = API_KEY, base_url: str = API_BASE):\n        self.base_url = base_url.rstrip(&quot;\u002F&quot;)\n        self._client = httpx.AsyncClient(\n            base_url=self.base_url,\n            headers={\n                &quot;Authorization&quot;: f&quot;Bearer {api_key}&quot;,\n                &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n            },\n            timeout=10.0,\n        )\n\n    async def send(self, to: str, subject: str, html: str,\n                   text: str | None = None, sender: str = MAIL_FROM) -&gt; dict:\n        payload = {&quot;from&quot;: sender, &quot;to&quot;: to, &quot;subject&quot;: subject, &quot;html&quot;: html}\n        if text:\n            payload[&quot;text&quot;] = text\n\n        response = await self._client.post(&quot;\u002Fv1\u002Femails&quot;, json=payload)\n        response.raise_for_status()\n        return response.json()\n\n    async def aclose(self) -&gt; None:\n        await self._client.aclose()\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Using it inside an async application:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import asyncio\nfrom async_client import AsyncEmailClient\n\n\nasync def main() -&gt; None:\n    client = AsyncEmailClient()\n    try:\n        result = await client.send(\n            to=&quot;user@example.com&quot;,\n            subject=&quot;Welcome to Acme&quot;,\n            html=&quot;&lt;h1&gt;Welcome!&lt;\u002Fh1&gt;&quot;,\n            text=&quot;Welcome!&quot;,\n        )\n        print(&quot;Sent:&quot;, result[&quot;id&quot;])\n    finally:\n        await client.aclose()\n\n\nasyncio.run(main())\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>In FastAPI, create one \u003Ccode>AsyncEmailClient\u003C\u002Fcode> at startup (a single connection pool for the whole app) and reuse it across requests rather than constructing a new client per send.\u003C\u002Fp>\n\u003Ch2>Sending with Templates and Dynamic Data\u003C\u002Fh2>\n\u003Cp>Hardcoding HTML strings in Python doesn't scale past a couple of emails. Two clean approaches:\u003C\u002Fp>\n\u003Cp>\u003Cstrong>1. Provider-side templates.\u003C\u002Fstrong> Store the template in your email provider, reference it by ID, and pass variables. This keeps copy out of your codebase and lets non-engineers edit content.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">client.session.post(\n    f&quot;{client.base_url}\u002Fv1\u002Femails&quot;,\n    json={\n        &quot;from&quot;: MAIL_FROM,\n        &quot;to&quot;: &quot;user@example.com&quot;,\n        &quot;template_id&quot;: &quot;password-reset&quot;,\n        &quot;variables&quot;: {\n            &quot;name&quot;: &quot;Sam&quot;,\n            &quot;reset_url&quot;: &quot;https:\u002F\u002Facme.com\u002Freset?t=abc123&quot;,\n        },\n    },\n    timeout=10,\n)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>2. Local rendering with Jinja2.\u003C\u002Fstrong> Render HTML in your app, then send the result. Good when content lives in your repo and is version-controlled with your code.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># render.py\nfrom jinja2 import Environment, FileSystemLoader, select_autoescape\n\nenv = Environment(\n    loader=FileSystemLoader(&quot;templates&quot;),\n    autoescape=select_autoescape([&quot;html&quot;, &quot;xml&quot;]),\n)\n\n\ndef render_reset_email(name: str, reset_url: str) -&gt; str:\n    template = env.get_template(&quot;password_reset.html&quot;)\n    return template.render(name=name, reset_url=reset_url)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Note \u003Ccode>select_autoescape\u003C\u002Fcode> — it escapes user-supplied values by default, preventing HTML\u002Fscript injection from data like display names. This matters: a user named \u003Ccode>&lt;script&gt;...\u003C\u002Fcode> should never break out into your email markup.\u003C\u002Fp>\n\u003Ch2>Handling Delivery Webhooks in Python\u003C\u002Fh2>\n\u003Cp>A \u003Ccode>2xx\u003C\u002Fcode> response means the message was \u003Cstrong>accepted\u003C\u002Fstrong>, not that it reached the inbox. To know what actually happened — delivered, bounced, opened, complained — you process \u003Cstrong>webhooks\u003C\u002Fstrong>: HTTP callbacks the provider sends to an endpoint you expose.\u003C\u002Fp>\n\u003Cp>Two non-negotiable rules for webhook handlers:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Verify the signature.\u003C\u002Fstrong> Anyone who learns your URL can POST fake events. Providers sign each payload with a shared secret; verify it before trusting the data.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Respond fast, process async.\u003C\u002Fstrong> Return \u003Ccode>200\u003C\u002Fcode> immediately and do heavy work (DB writes, alerts) in the background, or the provider may retry and you'll process duplicates.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Here's a FastAPI webhook receiver with HMAC signature verification:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># webhook.py\nimport hashlib\nimport hmac\nimport os\nfrom fastapi import FastAPI, Request, Header, HTTPException\n\napp = FastAPI()\nWEBHOOK_SECRET = os.environ[&quot;POSTWING_WEBHOOK_SECRET&quot;].encode()\n\n\ndef verify_signature(payload: bytes, signature: str) -&gt; bool:\n    expected = hmac.new(WEBHOOK_SECRET, payload, hashlib.sha256).hexdigest()\n    return hmac.compare_digest(expected, signature)\n\n\n@app.post(&quot;\u002Fwebhooks\u002Femail&quot;)\nasync def email_webhook(\n    request: Request,\n    x_signature: str = Header(default=&quot;&quot;),\n):\n    raw_body = await request.body()\n\n    if not verify_signature(raw_body, x_signature):\n        raise HTTPException(status_code=401, detail=&quot;Invalid signature&quot;)\n\n    event = await request.json()\n    event_type = event.get(&quot;type&quot;)\n    message_id = event.get(&quot;data&quot;, {}).get(&quot;id&quot;)\n\n    if event_type == &quot;email.delivered&quot;:\n        mark_delivered(message_id)\n    elif event_type == &quot;email.bounced&quot;:\n        suppress_address(event[&quot;data&quot;][&quot;to&quot;])\n    elif event_type == &quot;email.complained&quot;:\n        suppress_address(event[&quot;data&quot;][&quot;to&quot;])\n\n    return {&quot;ok&quot;: True}\n\n\ndef mark_delivered(message_id: str) -&gt; None:\n    ...  # update your DB\n\n\ndef suppress_address(address: str) -&gt; None:\n    ...  # stop sending to this address\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Two security details worth calling out: \u003Ccode>hmac.compare_digest\u003C\u002Fcode> is a \u003Cstrong>constant-time\u003C\u002Fstrong> comparison that prevents timing attacks (don't use \u003Ccode>==\u003C\u002Fcode> on signatures), and reading the \u003Cstrong>raw body\u003C\u002Fstrong> for verification matters because re-serializing the parsed JSON can change byte order and break the HMAC.\u003C\u002Fp>\n\u003Cp>When you receive a \u003Cstrong>bounce\u003C\u002Fstrong> or \u003Cstrong>complaint\u003C\u002Fstrong>, suppress that address immediately. Continuing to mail addresses that bounce or report spam is the fastest way to wreck your sender reputation and start landing in spam folders.\u003C\u002Fp>\n\u003Ch2>Common Mistakes When Sending Email in Python\u003C\u002Fh2>\n\u003Cp>These are the failure patterns we see most often in real Python codebases:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>No timeout on the HTTP call.\u003C\u002Fstrong> A missing \u003Ccode>timeout\u003C\u002Fcode> lets one slow request hang a worker. Always set one (\u003Ccode>timeout=10\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Hardcoded API keys.\u003C\u002Fstrong> Keys in source control are a security incident. Use environment variables or a secrets manager.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Treating \u003Ccode>2xx\u003C\u002Fcode> as \"delivered.\"\u003C\u002Fstrong> Acceptance is not delivery. Process webhooks for the real outcome.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retrying non-retryable errors.\u003C\u002Fstrong> Looping on a \u003Ccode>400\u003C\u002Fcode> or \u003Ccode>422\u003C\u002Fcode> wastes resources and never succeeds. Only retry \u003Ccode>429\u003C\u002Fcode>, \u003Ccode>5xx\u003C\u002Fcode>, and network errors.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retrying without idempotency.\u003C\u002Fstrong> Retries after a lost response double-send. Use an idempotency key per logical message.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring bounces and complaints.\u003C\u002Fstrong> Not suppressing bad addresses destroys deliverability over time.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No plain-text part.\u003C\u002Fstrong> HTML-only emails look worse to spam filters and break in text-only clients. Always include \u003Ccode>text\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Unverified webhooks.\u003C\u002Fstrong> An open webhook endpoint is an injection vector. Always verify the signature with constant-time comparison.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Creating a new client\u002Fsession per send.\u003C\u002Fstrong> You lose connection reuse. Instantiate one client and share it.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping SPF\u002FDKIM\u002FDMARC.\u003C\u002Fstrong> No amount of clean Python fixes a domain that isn't authenticated. Set these up first.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>Putting It All Together: A Production Send Function\u003C\u002Fh2>\n\u003Cp>Here's a consolidated, realistic function for a SaaS backend — configured client, retries, idempotency, and structured logging.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># mailer.py\nimport logging\nimport uuid\nimport requests\nimport tenacity\nfrom email_client import EmailClient, EmailError\n\nlogger = logging.getLogger(&quot;mailer&quot;)\n_client = EmailClient()\n\n\ndef _is_retryable(exc: BaseException) -&gt; bool:\n    if isinstance(exc, (requests.Timeout, requests.ConnectionError)):\n        return True\n    if isinstance(exc, EmailError):\n        return exc.status == 429 or exc.status &gt;= 500\n    return False\n\n\n@tenacity.retry(\n    retry=tenacity.retry_if_exception(_is_retryable),\n    wait=tenacity.wait_exponential_jitter(initial=0.5, max=30),\n    stop=tenacity.stop_after_attempt(5),\n    reraise=True,\n)\ndef send_transactional(to: str, subject: str, html: str, text: str,\n                       idempotency_key: str) -&gt; str:\n    result = _client.send(\n        to=to,\n        subject=subject,\n        html=html,\n        text=text,\n        idempotency_key=idempotency_key,\n    )\n    message_id = result[&quot;id&quot;]\n    logger.info(&quot;email_sent&quot;, extra={&quot;to&quot;: to, &quot;message_id&quot;: message_id})\n    return message_id\n\n\ndef send_receipt(order_id: str, to: str, html: str, text: str) -&gt; str:\n    return send_transactional(\n        to=to,\n        subject=f&quot;Your receipt for order {order_id}&quot;,\n        html=html,\n        text=text,\n        idempotency_key=f&quot;receipt-{order_id}&quot;,\n    )\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Deriving the idempotency key from a stable business identifier (\u003Ccode>f\"receipt-{order_id}\"\u003C\u002Fcode>) guarantees that, even across retries, restarts, or duplicate task executions, an order is emailed exactly once.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>What is the best way to send transactional email in Python?\u003C\u002Fh3>\n\u003Cp>The best way is to call a transactional \u003Cstrong>email API over HTTPS\u003C\u002Fstrong> using a client like \u003Ccode>requests\u003C\u002Fcode> (sync) or \u003Ccode>httpx\u003C\u002Fcode> (async). Load your API key from an environment variable, \u003Ccode>POST\u003C\u002Fcode> a JSON payload with \u003Ccode>from\u003C\u002Fcode>, \u003Ccode>to\u003C\u002Fcode>, \u003Ccode>subject\u003C\u002Fcode>, and \u003Ccode>html\u003C\u002Fcode>, set a request timeout, check the response status, and retry transient errors with exponential backoff. This is more reliable and far less code than raw \u003Ccode>smtplib\u003C\u002Fcode>\u002FSMTP for application-generated mail.\u003C\u002Fp>\n\u003Ch3>Should I use requests or httpx for a Python email API?\u003C\u002Fh3>\n\u003Cp>Use \u003Ccode>requests\u003C\u002Fcode> for synchronous code (Django views, Celery tasks, scripts) and \u003Ccode>httpx\u003C\u002Fcode> for async applications (FastAPI, asyncio workers). \u003Ccode>httpx\u003C\u002Fcode> also supports a synchronous client, so it's a fine single dependency for mixed codebases. Both have nearly identical APIs for \u003Ccode>POST\u003C\u002Fcode> requests, so the patterns in this guide apply to either.\u003C\u002Fp>\n\u003Ch3>Can I send transactional email with Python's built-in smtplib?\u003C\u002Fh3>\n\u003Cp>Yes, \u003Ccode>smtplib\u003C\u002Fcode> works, but it's rarely the best choice for transactional email. You'd manage a stateful SMTP connection, port-blocking issues, and get minimal delivery feedback. An HTTP email API is fewer lines of code, firewall-friendly (port 443), and returns message IDs plus webhook events. Reserve \u003Ccode>smtplib\u003C\u002Fcode> for internal scripts or legacy relays.\u003C\u002Fp>\n\u003Ch3>How do I handle retries when sending email in Python?\u003C\u002Fh3>\n\u003Cp>Retry only transient failures — \u003Ccode>429\u003C\u002Fcode>, \u003Ccode>5xx\u003C\u002Fcode> responses, and network timeouts — using exponential backoff with jitter (the \u003Ccode>tenacity\u003C\u002Fcode> library handles this cleanly). Never retry \u003Ccode>400\u003C\u002Fcode>, \u003Ccode>401\u003C\u002Fcode>, \u003Ccode>403\u003C\u002Fcode>, or \u003Ccode>422\u003C\u002Fcode>, which are caused by your request or config and won't succeed on retry. Crucially, pair retries with an \u003Cstrong>idempotency key\u003C\u002Fstrong> per logical message so a retry after a lost response never double-sends.\u003C\u002Fp>\n\u003Ch3>How do I process email delivery webhooks in Python?\u003C\u002Fh3>\n\u003Cp>Expose an HTTP endpoint (e.g., with FastAPI or Flask), read the \u003Cstrong>raw request body\u003C\u002Fstrong>, and verify the provider's signature using \u003Ccode>hmac\u003C\u002Fcode> with a constant-time comparison (\u003Ccode>hmac.compare_digest\u003C\u002Fcode>). Then parse the event, update your records for \u003Ccode>delivered\u003C\u002Fcode>, and suppress the address on \u003Ccode>bounced\u003C\u002Fcode> or \u003Ccode>complained\u003C\u002Fcode>. Return \u003Ccode>200\u003C\u002Fcode> quickly and do heavy processing asynchronously to avoid duplicate webhook retries.\u003C\u002Fp>\n\u003Ch3>How do I keep my email API key secure in Python?\u003C\u002Fh3>\n\u003Cp>Never hardcode the key in source code. Load it from an environment variable (using \u003Ccode>os.environ\u003C\u002Fcode> or \u003Ccode>python-dotenv\u003C\u002Fcode> in development) and use a dedicated secrets manager — AWS Secrets Manager, Vault, or Doppler — in production. Add \u003Ccode>.env\u003C\u002Fcode> to \u003Ccode>.gitignore\u003C\u002Fcode>, scope the key to send-only permissions if your provider supports it, and rotate it on a schedule or immediately if it leaks.\u003C\u002Fp>\n\u003Ch3>Why are my Python transactional emails landing in spam?\u003C\u002Fh3>\n\u003Cp>The cause is almost never your Python code — it's domain authentication. Make sure SPF, DKIM, and DMARC are correctly configured for your sending domain; following Google and Yahoo's 2024 sender requirements, these are effectively mandatory. Also send a plain-text part alongside HTML, suppress bounced and complained addresses promptly, and keep transactional mail on a separate subdomain from marketing campaigns.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Sending \u003Cstrong>transactional email in Python\u003C\u002Fstrong> well comes down to a handful of durable patterns, not a specific library. Call a transactional \u003Cstrong>python email api\u003C\u002Fstrong> over HTTPS, load secrets from the environment, always set a timeout, distinguish retryable from fatal errors, retry transient failures with backoff and an idempotency key, and process delivery webhooks with verified signatures so your app knows the real outcome of every message. Whether you use \u003Ccode>requests\u003C\u002Fcode> or \u003Ccode>httpx\u003C\u002Fcode>, those fundamentals are what separate a demo snippet from a system that delivers reliably at scale.\u003C\u002Fp>\n\u003Cp>Get domain authentication right first — SPF, DKIM, DMARC — then layer the client code from this guide on top. Do both, and your password resets, receipts, and OTP codes will land in the inbox, on time, every time.\u003C\u002Fp>\n\u003Ch2>Start Sending Transactional Email with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers: a fast, observable HTTP \u003Cstrong>email API\u003C\u002Fstrong> that drops straight into the Python patterns above, with idempotency keys, signed webhooks, suppression lists, and real-time delivery events out of the box. SPF, DKIM, and DMARC are handled for you, so you spend your time shipping features instead of fighting spam folders.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can fund your account and start sending without a corporate card, lengthy billing setup, or currency friction — ideal for global teams and crypto-native startups.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Get your API key and send your first transactional email in minutes →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","0888b459-9712-46da-b261-666e68ac6866","2026-06-24T18:28:18.044158+03:00","2026-07-15T23:46:21.192084+03:00","Send Transactional Emails with Python","Learn to send transactional email in Python with an email API: working requests\u002Fhttpx code, env config, retries, error handling, and webhook processing.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_0888b459-9712-46da-b261-666e68ac6866\u002FChatGPT_Image_Jul_15_2026_11_45_19_PM.png","2026-07-20T09:00:00+03:00",[124,125],"python email api","transactional email python",{"id":127,"body":128,"uuid":129,"created_at":130,"updated_at":131,"brand":13,"header":132,"short_body":133,"image":5,"published":17,"published_at":134,"tags":135},14,"\u003Cp>Adding \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> to an application — password resets, email verification, receipts, OTP codes, alerts — should take minutes, not days. With a modern HTTP \u003Cstrong>email API\u003C\u002Fstrong>, you can go from an empty project to a delivered, inbox-placed message in about ten minutes of work: install nothing more than your HTTP client of choice, set one environment variable, and make a single authenticated \u003Ccode>POST\u003C\u002Fcode> request.\u003C\u002Fp>\n\u003Cp>This is a hands-on tutorial for software engineers, SaaS founders, and CTOs wiring email into a Node.js app. We'll send a real transactional email step by step, then layer in the production concerns that separate a quick demo from a reliable system: environment-based configuration, robust error handling, retries with idempotency, and webhook handling for delivery events. Every code sample is realistic and runnable.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Quick answer:\u003C\u002Fstrong> To send transactional email in Node.js, call an HTTP email API with a single authenticated \u003Ccode>POST\u003C\u002Fcode> request — using the built-in \u003Ccode>fetch\u003C\u002Fcode> (Node 18+) or \u003Ccode>axios\u003C\u002Fcode>. Put the recipient, subject, and body in a JSON payload, your API key in an \u003Ccode>Authorization\u003C\u002Fcode> header, and you're sending in under ten minutes. Add retries, idempotency keys, and a webhook endpoint to make it production-grade.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>What Is Transactional Email in Node.js?\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Transactional email\u003C\u002Fstrong> is mail your application sends automatically in response to a user action or system event — one message to one recipient, triggered by something specific. Examples engineers ship constantly:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Account verification\u003C\u002Fstrong> and welcome emails on sign-up\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Password reset\u003C\u002Fstrong> links\u003C\u002Fli>\n\u003Cli>\u003Cstrong>One-time passcodes (OTP)\u003C\u002Fstrong> for two-factor authentication\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Receipts and invoices\u003C\u002Fstrong> after a purchase\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Alerts and notifications\u003C\u002Fstrong> (security warnings, usage limits, status changes)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>This is distinct from \u003Cstrong>marketing email\u003C\u002Fstrong> (bulk campaigns to a list). Transactional messages are expected by the user, time-sensitive, and high-stakes: if a password reset lands in spam, a user is locked out.\u003C\u002Fp>\n\u003Cp>In Node.js, the cleanest way to send these is through a \u003Cstrong>transactional email API\u003C\u002Fstrong> — an HTTP service you call from your backend. You make a request, the provider handles the actual SMTP delivery, signing, reputation, and tracking, and returns a message ID you can correlate with later events. No mail server to run, no SMTP connection pool to tune.\u003C\u002Fp>\n\u003Ch2>Why Use an Email API for Node.js Transactional Email?\u003C\u002Fh2>\n\u003Cp>You have two technical options for sending mail from Node.js: speak \u003Cstrong>SMTP\u003C\u002Fstrong> (typically via Nodemailer) or call an \u003Cstrong>HTTP email API\u003C\u002Fstrong>. Both work, but for application-driven transactional mail in modern, often serverless, deployments, an API is the better default.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Concern\u003C\u002Fth>\n\u003Cth>SMTP (Nodemailer)\u003C\u002Fth>\n\u003Cth>Email API (HTTP)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Integration\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Configure host, port, credentials; manage transport\u003C\u002Ftd>\n\u003Ctd>One \u003Ccode>POST\u003C\u002Fcode> request with \u003Ccode>fetch\u003C\u002Fcode>\u002F\u003Ccode>axios\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Latency per send\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Handshake + multi-step dialogue\u003C\u002Ftd>\n\u003Ctd>Single stateless request\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Serverless friendliness\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Long-lived connections awkward on Lambda\u002Fedge; ports often blocked\u003C\u002Ftd>\n\u003Ctd>Port 443 HTTPS — works everywhere\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Delivery feedback\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Accept\u002Freject at handshake only\u003C\u002Ftd>\n\u003Ctd>Synchronous status + async webhooks\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Built-in features\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Transport only\u003C\u002Ftd>\n\u003Ctd>Templates, suppression, idempotency, analytics\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Security\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Long-lived shared credentials\u003C\u002Ftd>\n\u003Ctd>Scoped, rotatable API keys\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The headline reason: an email API returns a \u003Cstrong>message ID synchronously\u003C\u002Fstrong> and pushes \u003Cstrong>delivery events\u003C\u002Fstrong> (delivered, bounced, complained) to your webhook endpoint. That observability loop is what lets you answer \"did the password reset actually arrive?\" — a question SMTP alone can't answer cleanly.\u003C\u002Fp>\n\u003Cp>For the full breakdown, see our \u003Ca href=\"\u002Fblog\u002Fsmtp-vs-email-api\">SMTP vs Email API guide\u003C\u002Fa>. For this tutorial, we'll use the API approach.\u003C\u002Fp>\n\u003Ch2>Prerequisites\u003C\u002Fh2>\n\u003Cp>Before you start, you need:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Node.js 18 or newer.\u003C\u002Fstrong> Node 18+ ships a global \u003Ccode>fetch\u003C\u002Fcode>, so you can send your first email with zero dependencies. Check with \u003Ccode>node --version\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>An email API key.\u003C\u002Fstrong> Sign up with a transactional email provider and generate an API key. (We'll use \u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> in the examples; the pattern is the same for any HTTP email API.)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A verified sending domain\u003C\u002Fstrong> with SPF, DKIM, and DMARC configured. Most providers walk you through adding a few DNS records. This is what keeps your mail out of spam — more on it below.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>That's it. No mail server, no SMTP credentials, no queue infrastructure required to get started.\u003C\u002Fp>\n\u003Ch2>Step 1: Configure Your Environment\u003C\u002Fh2>\n\u003Cp>Never hardcode an API key in source. Store it in an environment variable so it stays out of your repository and can differ per environment (dev, staging, production).\u003C\u002Fp>\n\u003Cp>Create a \u003Ccode>.env\u003C\u002Fcode> file in your project root:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># .env\nPOSTWING_API_KEY=pw_live_xxxxxxxxxxxxxxxxxxxxxxxx\nPOSTWING_API_URL=https:\u002F\u002Fapi.postwing.app\u002Fv1\nEMAIL_FROM=noreply@yourapp.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Add \u003Ccode>.env\u003C\u002Fcode> to your \u003Ccode>.gitignore\u003C\u002Fcode> immediately:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># .gitignore\n.env\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Load it in your app. Node 20.6+ can load env files natively with \u003Ccode>node --env-file=.env app.js\u003C\u002Fcode>, but the \u003Ccode>dotenv\u003C\u002Fcode> package works on every version:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">npm install dotenv\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F config.js\nimport &quot;dotenv\u002Fconfig&quot;;\n\nexport const config = {\n  apiKey: process.env.POSTWING_API_KEY,\n  apiUrl: process.env.POSTWING_API_URL ?? &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1&quot;,\n  from: process.env.EMAIL_FROM ?? &quot;noreply@yourapp.com&quot;,\n};\n\nif (!config.apiKey) {\n  throw new Error(&quot;POSTWING_API_KEY is not set. Check your .env file.&quot;);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Failing fast when the key is missing saves you from confusing 401 errors at runtime.\u003C\u002Fp>\n\u003Ch2>Step 2: Send Your First Node.js Transactional Email with fetch\u003C\u002Fh2>\n\u003Cp>Here's the minimal \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> send using the built-in \u003Ccode>fetch\u003C\u002Fcode> — no dependencies beyond Node 18+. This sends a password-reset email:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F send.js\nimport { config } from &quot;.\u002Fconfig.js&quot;;\n\nconst response = await fetch(`${config.apiUrl}\u002Fsend`, {\n  method: &quot;POST&quot;,\n  headers: {\n    &quot;Authorization&quot;: `Bearer ${config.apiKey}`,\n    &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n  },\n  body: JSON.stringify({\n    from: config.from,\n    to: &quot;user@example.com&quot;,\n    subject: &quot;Reset your password&quot;,\n    text: &quot;Click to reset your password: https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;,\n    html: `&lt;p&gt;Click to &lt;a href=&quot;https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;&gt;reset your password&lt;\u002Fa&gt;.&lt;\u002Fp&gt;`,\n  }),\n});\n\nconst data = await response.json();\nconsole.log(data);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Run it:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">node send.js\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The API responds immediately with a message ID and status:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;id&quot;: &quot;msg_9f8a7b6c5d4e&quot;,\n  &quot;status&quot;: &quot;queued&quot;,\n  &quot;to&quot;: &quot;user@example.com&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Store that \u003Ccode>id\u003C\u002Fcode>. It's the key you'll use to match delivery webhook events back to this specific send. That's your first transactional email — sent over HTTPS on port 443, which works even on locked-down cloud hosts where SMTP ports are blocked.\u003C\u002Fp>\n\u003Ch2>Step 3: Wrap It in a Reusable Function\u003C\u002Fh2>\n\u003Cp>A raw \u003Ccode>fetch\u003C\u002Fcode> call inline is fine for a demo. In a real app, wrap it in a small module with proper error handling so every part of your codebase sends mail the same way.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F mailer.js\nimport { config } from &quot;.\u002Fconfig.js&quot;;\n\nexport class EmailError extends Error {\n  constructor(message, { status, body } = {}) {\n    super(message);\n    this.name = &quot;EmailError&quot;;\n    this.status = status;\n    this.body = body;\n  }\n}\n\nexport async function sendEmail({ to, subject, text, html, from }) {\n  const response = await fetch(`${config.apiUrl}\u002Fsend`, {\n    method: &quot;POST&quot;,\n    headers: {\n      &quot;Authorization&quot;: `Bearer ${config.apiKey}`,\n      &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n    },\n    body: JSON.stringify({\n      from: from ?? config.from,\n      to,\n      subject,\n      text,\n      html,\n    }),\n  });\n\n  if (!response.ok) {\n    let body;\n    try {\n      body = await response.json();\n    } catch {\n      body = await response.text();\n    }\n    throw new EmailError(`Email send failed (${response.status})`, {\n      status: response.status,\n      body,\n    });\n  }\n\n  return response.json();\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Now sending from anywhere in your app is a one-liner:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { sendEmail } from &quot;.\u002Fmailer.js&quot;;\n\nawait sendEmail({\n  to: user.email,\n  subject: &quot;Welcome to YourApp&quot;,\n  text: `Hi ${user.name}, welcome aboard!`,\n  html: `&lt;p&gt;Hi ${user.name}, welcome aboard!&lt;\u002Fp&gt;`,\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Using axios instead of fetch\u003C\u002Fh3>\n\u003Cp>If your stack already uses \u003Ccode>axios\u003C\u002Fcode>, the \u003Cstrong>email API in Node.js\u003C\u002Fstrong> pattern is nearly identical. \u003Ccode>axios\u003C\u002Fcode> throws on non-2xx responses by default, which simplifies error handling:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F mailer-axios.js\nimport axios from &quot;axios&quot;;\nimport { config } from &quot;.\u002Fconfig.js&quot;;\n\nconst client = axios.create({\n  baseURL: config.apiUrl,\n  headers: { Authorization: `Bearer ${config.apiKey}` },\n  timeout: 10_000,\n});\n\nexport async function sendEmail({ to, subject, text, html, from }) {\n  try {\n    const { data } = await client.post(&quot;\u002Fsend&quot;, {\n      from: from ?? config.from,\n      to,\n      subject,\n      text,\n      html,\n    });\n    return data;\n  } catch (err) {\n    if (axios.isAxiosError(err) &amp;&amp; err.response) {\n      throw new Error(\n        `Email send failed (${err.response.status}): ${JSON.stringify(err.response.data)}`\n      );\n    }\n    throw err;\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Use whichever HTTP client your project already depends on. \u003Ccode>fetch\u003C\u002Fcode> keeps your dependency count at zero; \u003Ccode>axios\u003C\u002Fcode> gives you interceptors, timeouts, and automatic JSON handling out of the box.\u003C\u002Fp>\n\u003Ch2>Step 4: Handle Errors and Retries Properly\u003C\u002Fh2>\n\u003Cp>Network calls fail. A production \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> integration must distinguish between errors it should retry and errors it must not.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Status\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Retry?\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>400\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Bad request (malformed payload, invalid address)\u003C\u002Ftd>\n\u003Ctd>No — fix the input\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>401\u003C\u002Fcode> \u002F \u003Ccode>403\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Invalid or unauthorized API key\u003C\u002Ftd>\n\u003Ctd>No — fix credentials\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>422\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Validation error (e.g., unverified sender)\u003C\u002Ftd>\n\u003Ctd>No — fix configuration\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>429\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Rate limited\u003C\u002Ftd>\n\u003Ctd>Yes — back off and retry\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>500\u003C\u002Fcode>–\u003Ccode>504\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Provider-side error\u003C\u002Ftd>\n\u003Ctd>Yes — retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Network timeout \u002F \u003Ccode>ECONNRESET\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Transient connectivity\u003C\u002Ftd>\n\u003Ctd>Yes — retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The rule of thumb: \u003Cstrong>retry transient failures (429, 5xx, network errors) with exponential backoff; never retry client errors (4xx other than 429)\u003C\u002Fstrong> — they'll just fail again. Here's a retry wrapper:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F retry.js\nconst RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);\n\nconst sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));\n\nexport async function withRetry(fn, { retries = 3, baseDelay = 500 } = {}) {\n  let lastError;\n\n  for (let attempt = 0; attempt &lt;= retries; attempt++) {\n    try {\n      return await fn();\n    } catch (err) {\n      lastError = err;\n\n      const status = err.status;\n      const isRetryable =\n        status === undefined || RETRYABLE_STATUS.has(status);\n\n      if (!isRetryable || attempt === retries) break;\n\n      \u002F\u002F Exponential backoff with jitter: 500ms, 1s, 2s (+ random)\n      const delay = baseDelay * 2 ** attempt + Math.random() * 200;\n      await sleep(delay);\n    }\n  }\n\n  throw lastError;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Wrap your send:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { withRetry } from &quot;.\u002Fretry.js&quot;;\nimport { sendEmail } from &quot;.\u002Fmailer.js&quot;;\n\nawait withRetry(() =&gt;\n  sendEmail({\n    to: user.email,\n    subject: &quot;Your one-time code&quot;,\n    text: `Your code is 481920. It expires in 5 minutes.`,\n  })\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Use idempotency keys to avoid duplicate sends\u003C\u002Fh3>\n\u003Cp>Retries introduce a danger: if a request actually succeeded but the response was lost, retrying double-sends the email. A user getting two receipts — or two charge confirmations — erodes trust.\u003C\u002Fp>\n\u003Cp>Most transactional email APIs support an \u003Cstrong>idempotency key\u003C\u002Fstrong>: a unique value you attach to a request so the provider deduplicates retries server-side. Generate one per logical send and reuse it across retries:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { randomUUID } from &quot;node:crypto&quot;;\n\nexport async function sendEmailIdempotent(payload, idempotencyKey = randomUUID()) {\n  const response = await fetch(`${config.apiUrl}\u002Fsend`, {\n    method: &quot;POST&quot;,\n    headers: {\n      &quot;Authorization&quot;: `Bearer ${config.apiKey}`,\n      &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n      &quot;Idempotency-Key&quot;: idempotencyKey,\n    },\n    body: JSON.stringify(payload),\n  });\n  \u002F\u002F ...same error handling as before\n  return response.json();\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Generate the key once, outside the retry loop, so all attempts share it. The provider returns the original result for repeated keys instead of sending again.\u003C\u002Fp>\n\u003Ch2>Step 5: Send Emails Without Blocking the Request\u003C\u002Fh2>\n\u003Cp>A common mistake is awaiting the email send inside an HTTP request handler. If the email API is slow, your user waits. Worse, if it fails, you might fail the whole operation (the user signed up but got a 500).\u003C\u002Fp>\n\u003Cp>Email delivery should be \u003Cstrong>decoupled from the user's request\u003C\u002Fstrong>. For most apps, the simplest correct pattern is to enqueue the send and respond immediately:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F signup route (Express)\napp.post(&quot;\u002Fsignup&quot;, async (req, res) =&gt; {\n  const user = await createUser(req.body);\n\n  \u002F\u002F Don't block the response on email delivery.\n  \u002F\u002F Enqueue it; a worker handles the actual send with retries.\n  await emailQueue.add(&quot;welcome&quot;, {\n    to: user.email,\n    name: user.name,\n  });\n\n  res.status(201).json({ id: user.id });\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>A background worker (BullMQ, a serverless queue, or a simple cron-drained table) then calls your \u003Ccode>sendEmail\u003C\u002Fcode> with retries. This keeps your API fast and ensures a transient email outage doesn't break sign-up. For lower-traffic apps, even a fire-and-forget \u003Ccode>sendEmail(...).catch(logError)\u003C\u002Fcode> after responding is better than blocking the user — though a real queue gives you durability and retry guarantees.\u003C\u002Fp>\n\u003Ch2>Step 6: Handle Delivery Webhooks\u003C\u002Fh2>\n\u003Cp>Sending is only half the loop. To know what actually happened to each message — \u003Cstrong>delivered\u003C\u002Fstrong>, \u003Cstrong>bounced\u003C\u002Fstrong>, \u003Cstrong>opened\u003C\u002Fstrong>, \u003Cstrong>complained\u003C\u002Fstrong> (marked as spam) — you handle \u003Cstrong>webhooks\u003C\u002Fstrong>: HTTP callbacks the provider sends to your endpoint as events occur.\u003C\u002Fp>\n\u003Cp>Here's a webhook handler in Express that processes delivery events:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F webhooks.js\nimport express from &quot;express&quot;;\nimport crypto from &quot;node:crypto&quot;;\n\nconst router = express.Router();\n\n\u002F\u002F Use the raw body so we can verify the signature.\nrouter.post(\n  &quot;\u002Fwebhooks\u002Femail&quot;,\n  express.raw({ type: &quot;application\u002Fjson&quot; }),\n  (req, res) =&gt; {\n    const signature = req.header(&quot;X-Webhook-Signature&quot;);\n    const secret = process.env.POSTWING_WEBHOOK_SECRET;\n\n    \u002F\u002F 1. Verify the webhook is genuinely from your provider.\n    const expected = crypto\n      .createHmac(&quot;sha256&quot;, secret)\n      .update(req.body) \u002F\u002F raw Buffer\n      .digest(&quot;hex&quot;);\n\n    const valid =\n      signature &amp;&amp;\n      crypto.timingSafeEqual(\n        Buffer.from(signature),\n        Buffer.from(expected)\n      );\n\n    if (!valid) {\n      return res.status(401).send(&quot;Invalid signature&quot;);\n    }\n\n    \u002F\u002F 2. Parse and handle the event.\n    const event = JSON.parse(req.body.toString());\n\n    switch (event.type) {\n      case &quot;delivered&quot;:\n        console.log(`Delivered: ${event.message_id} -&gt; ${event.recipient}`);\n        break;\n      case &quot;bounced&quot;:\n        \u002F\u002F A hard bounce means the address is invalid — suppress it.\n        markEmailInvalid(event.recipient);\n        break;\n      case &quot;complained&quot;:\n        \u002F\u002F The user marked it as spam — stop emailing them.\n        unsubscribe(event.recipient);\n        break;\n      default:\n        console.log(`Unhandled event: ${event.type}`);\n    }\n\n    \u002F\u002F 3. Respond 200 quickly so the provider doesn't retry.\n    res.sendStatus(200);\n  }\n);\n\nexport default router;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Three rules for reliable webhook handling:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Verify the signature.\u003C\u002Fstrong> Never trust an unauthenticated webhook — anyone could POST fake events. Use the provider's signing secret and a constant-time comparison (\u003Ccode>crypto.timingSafeEqual\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Respond fast (2xx) and do work asynchronously.\u003C\u002Fstrong> Providers retry webhooks that don't return 2xx promptly. Acknowledge first, then process (or enqueue) the heavy work.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Act on bounces and complaints.\u003C\u002Fstrong> Hard bounces mean dead addresses — suppress them. Complaints mean spam reports — stop mailing that user. Ignoring these tanks your sender reputation over time.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Webhooks close the observability loop that makes the \u003Cstrong>email API in Node.js\u003C\u002Fstrong> approach so much stronger than fire-and-forget SMTP.\u003C\u002Fp>\n\u003Ch2>Step 7: Make Sure Your Email Reaches the Inbox\u003C\u002Fh2>\n\u003Cp>Your code can be perfect and your mail can still land in spam if your domain isn't authenticated. Inbox placement depends on \u003Cstrong>domain reputation and authentication\u003C\u002Fstrong>, not your Node.js code. Three DNS-based standards are non-negotiable:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF (Sender Policy Framework)\u003C\u002Fstrong> — lists which servers may send for your domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM (DomainKeys Identified Mail)\u003C\u002Fstrong> — cryptographically signs your messages so recipients can verify they weren't tampered with.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC (Domain-based Message Authentication)\u003C\u002Fstrong> — tells receiving servers what to do with mail that fails SPF\u002FDKIM, and gives you reporting.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Since Google and Yahoo's \u003Ca href=\"https:\u002F\u002Fblog.google\u002Fproducts\u002Fgmail\u002Fgmail-security-authentication-spam-protection\u002F\">2024 bulk-sender requirements\u003C\u002Fa>, authenticated mail is effectively mandatory. A good provider configures and signs these for you — you add a few DNS records once. For a deeper dive, see our guide on \u003Ca href=\"\u002Fblog\u002Fspf-dkim-dmarc-email-authentication\">SPF, DKIM, and DMARC\u003C\u002Fa>.\u003C\u002Fp>\n\u003Cp>Beyond authentication:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Use a real \u003Ccode>From\u003C\u002Fcode> address\u003C\u002Fstrong> on a verified subdomain (e.g., \u003Ccode>noreply@mail.yourapp.com\u003C\u002Fcode>), not a free mailbox like Gmail.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep transactional and marketing streams separate\u003C\u002Fstrong> so a campaign can't poison the reputation that delivers your password resets.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Honor bounces and complaints\u003C\u002Fstrong> via the webhook handler above.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Common Mistakes with Node.js Transactional Email\u003C\u002Fh2>\n\u003Cp>These mistakes quietly break reliability and deliverability in real Node.js apps:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Hardcoding the API key.\u003C\u002Fstrong> Committing a key to your repo is a security incident. Use environment variables or a secrets manager, and add \u003Ccode>.env\u003C\u002Fcode> to \u003Ccode>.gitignore\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocking the HTTP response on email delivery.\u003C\u002Fstrong> Awaiting a slow or failing email API inside a request handler makes users wait and can fail core operations. Decouple the send via a queue or fire-and-forget with error logging.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retrying without idempotency keys.\u003C\u002Fstrong> Naive retries double-send receipts and OTPs when a response is lost. Attach an idempotency key generated once per logical send.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retrying non-retryable errors.\u003C\u002Fstrong> Re-sending on a \u003Ccode>400\u003C\u002Fcode> or \u003Ccode>401\u003C\u002Fcode> just burns rate limits. Only retry \u003Ccode>429\u003C\u002Fcode> and \u003Ccode>5xx\u003C\u002Fcode> and network failures, with exponential backoff.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Treating a 2xx as \"delivered.\"\u003C\u002Fstrong> A successful API response means \u003Cem>accepted for delivery\u003C\u002Fem>, not inboxed. Process delivery webhooks to know the real outcome.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Not verifying webhook signatures.\u003C\u002Fstrong> An unauthenticated webhook endpoint lets anyone inject fake events. Always verify the HMAC signature with a constant-time comparison.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring bounces and complaints.\u003C\u002Fstrong> Repeatedly mailing dead addresses or spam-reporters destroys sender reputation. Suppress hard bounces and unsubscribe complainers automatically.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping SPF, DKIM, and DMARC.\u003C\u002Fstrong> The single most common reason transactional mail lands in spam. Authenticate your domain before you ship.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>How do I send transactional email in Node.js?\u003C\u002Fh3>\n\u003Cp>To send transactional email in Node.js, call an HTTP email API with a single authenticated \u003Ccode>POST\u003C\u002Fcode> request. Use the built-in \u003Ccode>fetch\u003C\u002Fcode> (Node 18+) or \u003Ccode>axios\u003C\u002Fcode>, put your API key in an \u003Ccode>Authorization: Bearer\u003C\u002Fcode> header, and send a JSON payload with \u003Ccode>from\u003C\u002Fcode>, \u003Ccode>to\u003C\u002Fcode>, \u003Ccode>subject\u003C\u002Fcode>, and \u003Ccode>text\u003C\u002Fcode>\u002F\u003Ccode>html\u003C\u002Fcode> fields. The provider handles SMTP delivery and returns a message ID. You can send your first email in under ten minutes, then add retries and webhook handling for production.\u003C\u002Fp>\n\u003Ch3>Should I use fetch or axios for an email API in Node.js?\u003C\u002Fh3>\n\u003Cp>Either works. The built-in \u003Ccode>fetch\u003C\u002Fcode> (available globally in Node 18+) keeps your dependencies at zero and is ideal for simple sends. \u003Ccode>axios\u003C\u002Fcode> is a good fit if your project already uses it — it provides interceptors, configurable timeouts, automatic JSON parsing, and throws on non-2xx responses, which can simplify error handling. Pick whichever your stack already depends on.\u003C\u002Fp>\n\u003Ch3>Do I need Nodemailer for transactional email?\u003C\u002Fh3>\n\u003Cp>Not if you use an HTTP email API. Nodemailer is an SMTP client — useful when you must speak SMTP (e.g., a relay for third-party tools). For application-driven transactional email, an HTTP API called with \u003Ccode>fetch\u003C\u002Fcode> or \u003Ccode>axios\u003C\u002Fcode> is simpler, faster on serverless platforms, and gives you synchronous message IDs plus delivery webhooks that raw SMTP doesn't.\u003C\u002Fp>\n\u003Ch3>How do I prevent sending duplicate emails on retry?\u003C\u002Fh3>\n\u003Cp>Use an idempotency key. Generate a unique value (such as \u003Ccode>crypto.randomUUID()\u003C\u002Fcode>) once per logical send, attach it as an \u003Ccode>Idempotency-Key\u003C\u002Fcode> header, and reuse the same key across all retry attempts. The email API deduplicates requests with the same key server-side, so a retried request that originally succeeded returns the original result instead of sending again.\u003C\u002Fp>\n\u003Ch3>How do I handle email delivery webhooks in Node.js?\u003C\u002Fh3>\n\u003Cp>Create an HTTP endpoint (e.g., an Express route) that receives the provider's POST callbacks. Read the raw request body, verify the HMAC signature with your webhook secret using \u003Ccode>crypto.timingSafeEqual\u003C\u002Fcode>, then parse and handle events like \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode>. Respond with a 2xx status quickly and do heavy work asynchronously so the provider doesn't retry the delivery.\u003C\u002Fp>\n\u003Ch3>Why are my Node.js transactional emails going to spam?\u003C\u002Fh3>\n\u003Cp>Almost always because of missing or misconfigured domain authentication, not your code. Set up SPF, DKIM, and DMARC DNS records for your sending domain — these are effectively required by Gmail and Yahoo since 2024. Also send from a verified subdomain rather than a free mailbox, keep transactional and marketing mail on separate streams, and suppress addresses that hard-bounce or report spam.\u003C\u002Fp>\n\u003Ch3>Can I send transactional email from a serverless function?\u003C\u002Fh3>\n\u003Cp>Yes — and an HTTP email API is the ideal fit. Serverless platforms (AWS Lambda, Vercel, Cloudflare Workers) often block SMTP ports and don't suit long-lived connections. An email API uses stateless HTTPS on port 443, so a single \u003Ccode>fetch\u003C\u002Fcode> from your function just works. Pair it with a queue for retries if your function has a short timeout.\u003C\u002Fp>\n\u003Ch3>How fast can I integrate an email API in a Node.js app?\u003C\u002Fh3>\n\u003Cp>About ten minutes for a working send: sign up and get an API key, set one environment variable, and make a \u003Ccode>POST\u003C\u002Fcode> request with \u003Ccode>fetch\u003C\u002Fcode>. Production-hardening — retries with idempotency, a webhook handler, and domain authentication — adds an hour or two but is straightforward to copy from the patterns in this guide.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Sending \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> is genuinely a ten-minute task to get started, and a well-understood path to production-grade. With Node 18+ and a modern HTTP email API, your first send is a single authenticated \u003Ccode>fetch\u003C\u002Fcode> request — no mail server, no SMTP tuning, no extra dependencies. From there, the production checklist is short and mechanical:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Configure\u003C\u002Fstrong> the API key via environment variables, never in source.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Wrap\u003C\u002Fstrong> sends in a reusable function with proper error handling.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retry\u003C\u002Fstrong> transient failures (429, 5xx, network) with exponential backoff and idempotency keys.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Decouple\u003C\u002Fstrong> delivery from the user's request with a queue.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Handle webhooks\u003C\u002Fstrong> to track delivered, bounced, and complained events — and act on them.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authenticate\u003C\u002Fstrong> your domain with SPF, DKIM, and DMARC so mail reaches the inbox.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Nail those, and your password resets, receipts, and OTPs arrive reliably and on time — with full visibility into what happened to every message.\u003C\u002Fp>\n\u003Ch2>Start Sending with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers: a fast, observable HTTP \u003Cstrong>email API\u003C\u002Fstrong> you can call from Node.js with a single \u003Ccode>fetch\u003C\u002Fcode> or \u003Ccode>axios\u003C\u002Fcode> request, plus real-time delivery webhooks, idempotency support, suppression lists, and analytics. SPF, DKIM, and DMARC are handled for you, so your \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> lands in the inbox instead of spam.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can fund your account and start sending without a corporate card, lengthy billing setup, or currency friction — ideal for global teams and crypto-native startups.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Get your API key and send your first Node.js email in 10 minutes →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","0b5474da-69d8-48dc-a5b2-e9fa95e49810","2026-06-24T18:28:18.039383+03:00","2026-06-24T18:28:18.039394+03:00","Send Transactional Emails with Node.js in 10 Minutes","Send Node.js transactional email in 10 minutes. Step-by-step guide to an email API with fetch\u002Faxios, env config, error handling, retries, and webhooks.","2026-07-16T09:00:00+03:00",[136,137],"nodejs transactional email","email api nodejs",{"id":139,"body":140,"uuid":141,"created_at":142,"updated_at":143,"brand":13,"header":144,"short_body":145,"image":146,"published":17,"published_at":147,"tags":148},13,"\u003Cp>Email monitoring is the practice of continuously tracking what happens to every transactional message your application sends — whether it was accepted, delivered, bounced, deferred, marked as spam, or never arrived at all. For a SaaS product, those emails are not marketing extras. They are password resets, payment receipts, login codes, invoices, and onboarding flows that users depend on to use your software at all. When one of them silently fails, you don't get an error in your logs. You get a support ticket, a churned customer, or worse — nothing, because the user just gave up.\u003C\u002Fp>\n\u003Cp>That silence is the core problem. A failed API call throws an exception you can catch. A failed email returns a \u003Ccode>250 OK\u003C\u002Fcode> from your provider and then vanishes somewhere between the SMTP handshake and the recipient's inbox. Without email monitoring, you are flying blind on one of the most business-critical paths in your entire product.\u003C\u002Fp>\n\u003Cp>This guide is a practical, developer-focused walkthrough of why transactional email monitoring matters, exactly what to track, how to instrument it with webhooks and dashboards, the metrics that actually predict trouble, and the mistakes that quietly cost SaaS companies real revenue. By the end you'll have a concrete checklist you can implement this week.\u003C\u002Fp>\n\u003Ch2>What Is Transactional Email Monitoring?\u003C\u002Fh2>\n\u003Cp>Transactional email monitoring is the real-time observation and alerting layer over your transactional email pipeline. It answers a deceptively simple question: \u003Cem>did the email my code sent actually reach the person it was meant for, and if not, why?\u003C\u002Fem>\u003C\u002Fp>\n\u003Cp>It combines several distinct capabilities:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Delivery tracking\u003C\u002Fstrong> — confirming the receiving mail server accepted the message.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bounce tracking\u003C\u002Fstrong> — capturing hard and soft bounces and the reason codes behind them.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Engagement tracking\u003C\u002Fstrong> — opens, clicks, and (importantly) spam complaints.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Reputation tracking\u003C\u002Fstrong> — monitoring your sending domain and IP health over time.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Alerting\u003C\u002Fstrong> — notifying your team when any of the above crosses a dangerous threshold.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>The key distinction from application logging is that the most important events happen \u003Cem>after\u003C\u002Fem> your server is done. Your code's job ends the moment your email provider accepts the request. Everything that determines whether the user actually gets the email — the recipient's mail server, spam filters, reputation checks, greylisting — happens downstream and asynchronously. Email monitoring is how you regain visibility into that downstream half.\u003C\u002Fp>\n\u003Ch3>Email monitoring vs. email delivery tracking\u003C\u002Fh3>\n\u003Cp>These terms overlap, so it's worth being precise. \u003Cstrong>Email delivery tracking\u003C\u002Fstrong> is the narrower act of following an individual message through its lifecycle — sent → delivered → bounced. \u003Cstrong>Email monitoring\u003C\u002Fstrong> is the broader, ongoing discipline: aggregating that per-message tracking data, watching trends, correlating it with reputation signals, and alerting on anomalies. Delivery tracking tells you what happened to \u003Cem>one\u003C\u002Fem> email. Monitoring tells you whether your \u003Cem>whole sending system\u003C\u002Fem> is healthy.\u003C\u002Fp>\n\u003Ch2>Why Transactional Email Monitoring Is Non-Negotiable for SaaS\u003C\u002Fh2>\n\u003Cp>Most teams add email monitoring only after an incident. Here's why you want it before.\u003C\u002Fp>\n\u003Ch3>1. Transactional emails are on your critical path\u003C\u002Fh3>\n\u003Cp>Consider what breaks for a user when a transactional email never arrives:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Signup verification\u003C\u002Fstrong> doesn't arrive → the user can't activate the account → 100% of that signup is lost.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Password reset\u003C\u002Fstrong> doesn't arrive → the user is locked out → support ticket or churn.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Payment receipt \u002F invoice\u003C\u002Fstrong> doesn't arrive → billing disputes, compliance issues, and chargebacks.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>2FA \u002F login code\u003C\u002Fstrong> doesn't arrive → the user literally cannot log in.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Each of these is a hard stop in your funnel. Marketing email failures cost you a little engagement. Transactional email failures cost you the customer.\u003C\u002Fp>\n\u003Ch3>2. Failures are invisible by default\u003C\u002Fh3>\n\u003Cp>Your provider returns success the instant it queues the message. After that, the failure modes are silent unless you're listening:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>The recipient server soft-bounces and the message gets deferred for hours.\u003C\u002Fli>\n\u003Cli>A spam filter quarantines it — no bounce, no delivery, just gone.\u003C\u002Fli>\n\u003Cli>Your sending domain's reputation drops and a whole ISP starts rejecting you.\u003C\u002Fli>\n\u003Cli>A misconfigured DNS record (expired DKIM key, broken SPF) causes blanket rejection.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>None of these show up in your application logs. Email monitoring is the only way to see them.\u003C\u002Fp>\n\u003Ch3>3. Reputation problems compound\u003C\u002Fh3>\n\u003Cp>Deliverability is governed by reputation, and reputation decays slowly then collapses suddenly. A rising complaint rate or a spike in spam-trap hits will gradually erode your inbox placement until one day a major ISP blocks you entirely. Catching the early trend is the difference between a config tweak and a multi-week recovery while real users miss critical emails.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Industry context:\u003C\u002Fstrong> Major mailbox providers like Google and Yahoo now enforce sender requirements that include keeping spam complaint rates below \u003Cstrong>0.3%\u003C\u002Fstrong> (with 0.1% as the target), valid SPF\u002FDKIM\u002FDMARC authentication, and one-click unsubscribe for bulk senders. You cannot stay under a complaint-rate threshold you aren't measuring. Monitoring is how you stay compliant. \u003Cem>(See Google's and Yahoo's 2024 sender guidelines.)\u003C\u002Fem>\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>What to Track: The Core Email Monitoring Events\u003C\u002Fh2>\n\u003Cp>Effective email monitoring starts with capturing the right events. Modern transactional email providers expose these as webhook event types. Here's what each means and why it matters.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Event\u003C\u002Fth>\n\u003Cth>What it means\u003C\u002Fth>\n\u003Cth>Why you monitor it\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Sent \u002F Accepted\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Your provider accepted the request\u003C\u002Ftd>\n\u003Ctd>Baseline volume; the denominator for every rate\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Delivered\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>The recipient mail server accepted the message\u003C\u002Ftd>\n\u003Ctd>Your true success signal\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Bounced (hard)\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Permanent failure — invalid address, domain doesn't exist\u003C\u002Ftd>\n\u003Ctd>Clean your list; protects reputation\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Bounced (soft)\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Temporary failure — mailbox full, server down, greylisting\u003C\u002Ftd>\n\u003Ctd>Retry logic; transient issues\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Deferred\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Recipient server asked you to retry later\u003C\u002Ftd>\n\u003Ctd>Normal in small doses; a spike signals throttling\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Complained (spam)\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Recipient hit \"mark as spam\"\u003C\u002Ftd>\n\u003Ctd>The single most damaging signal to reputation\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Opened\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Recipient opened the message\u003C\u002Ftd>\n\u003Ctd>Engagement proxy (imperfect — see below)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Clicked\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Recipient clicked a tracked link\u003C\u002Ftd>\n\u003Ctd>Confirms the email was actionable\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Unsubscribed\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Recipient opted out\u003C\u002Ftd>\n\u003Ctd>Compliance and list hygiene\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Dropped \u002F Suppressed\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Your provider blocked the send (suppression list)\u003C\u002Ftd>\n\u003Ctd>Catches repeat-failure addresses before they hurt you\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>Delivered ≠ Sent\u003C\u002Fh3>\n\u003Cp>The most common beginner mistake is treating \"sent\" as success. \u003Cem>Sent\u003C\u002Fem> only means your provider got the request. \u003Cem>Delivered\u003C\u002Fem> means the recipient's server took it. The gap between those two numbers is exactly where your problems live. Always measure delivery rate as \u003Ccode>delivered \u002F sent\u003C\u002Fcode>, and watch that ratio, not raw send counts.\u003C\u002Fp>\n\u003Ch3>A note on open tracking\u003C\u002Fh3>\n\u003Cp>Open tracking relies on a tracking pixel, and privacy features like Apple Mail Privacy Protection pre-fetch images, inflating open rates. Treat opens as a soft, directional signal — useful for detecting a \u003Cem>collapse\u003C\u002Fem> (which usually means a deliverability problem) but unreliable as an absolute engagement number. Bounces, complaints, and delivery confirmations are far more trustworthy for monitoring.\u003C\u002Fp>\n\u003Ch2>The Metrics That Predict Trouble\u003C\u002Fh2>\n\u003Cp>Raw events are noise until you turn them into rates and watch the trends. These are the metrics that matter, with rough healthy thresholds for transactional mail.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Metric\u003C\u002Fth>\n\u003Cth>Formula\u003C\u002Fth>\n\u003Cth>Healthy range\u003C\u002Fth>\n\u003Cth>Alert when\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Delivery rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>delivered \u002F sent\u003C\u002Ftd>\n\u003Ctd>&gt; 98%\u003C\u002Ftd>\n\u003Ctd>&lt; 95%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Hard bounce rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>hard bounces \u002F sent\u003C\u002Ftd>\n\u003Ctd>&lt; 0.5%\u003C\u002Ftd>\n\u003Ctd>&gt; 2%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Soft bounce rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>soft bounces \u002F sent\u003C\u002Ftd>\n\u003Ctd>&lt; 1%\u003C\u002Ftd>\n\u003Ctd>sustained spike\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Complaint rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>complaints \u002F delivered\u003C\u002Ftd>\n\u003Ctd>&lt; 0.1%\u003C\u002Ftd>\n\u003Ctd>&gt; 0.3%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Deferral rate\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>deferrals \u002F sent\u003C\u002Ftd>\n\u003Ctd>low \u002F spiky\u003C\u002Ftd>\n\u003Ctd>sustained spike\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>A few rules of thumb:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Delivery rate\u003C\u002Fstrong> is your top-line health metric. A slow decline almost always means a reputation or authentication issue.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Hard bounce rate\u003C\u002Fstrong> above ~2% will get you throttled or blocked. Spikes usually mean a bad import, a signup form without validation, or a list quality problem.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Complaint rate\u003C\u002Fstrong> is the one ISPs care about most. Even a small sustained rise is an emergency. Transactional mail should rarely generate complaints — if it does, your \"transactional\" mail may actually be marketing in disguise.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Deferral spikes\u003C\u002Fstrong> often mean an ISP is throttling you, which is an early reputation warning.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Monitor these per \u003Cstrong>sending domain\u003C\u002Fstrong> and ideally per \u003Cstrong>email type\u003C\u002Fstrong> (password resets vs. receipts vs. notifications), because a problem isolated to one stream is invisible in the aggregate.\u003C\u002Fp>\n\u003Ch2>How to Set Up Email Monitoring: A Practical Walkthrough\u003C\u002Fh2>\n\u003Cp>Email monitoring has two halves: \u003Cstrong>ingesting events\u003C\u002Fstrong> (webhooks) and \u003Cstrong>acting on them\u003C\u002Fstrong> (dashboards + alerts). Here's how to build both.\u003C\u002Fp>\n\u003Ch3>Step 1: Receive delivery events via webhooks\u003C\u002Fh3>\n\u003Cp>Your transactional email provider should POST a webhook to your endpoint for every lifecycle event. This is the foundation of email delivery tracking — it's how downstream events get back into your system. A minimal Node.js webhook receiver:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F POST \u002Fwebhooks\u002Femail — receives Postwing delivery events\nimport express from &quot;express&quot;;\n\nconst app = express();\napp.use(express.json());\n\napp.post(&quot;\u002Fwebhooks\u002Femail&quot;, async (req, res) =&gt; {\n  const event = req.body; \u002F\u002F { type, email, messageId, timestamp, reason }\n\n  \u002F\u002F Acknowledge fast; do heavy work async so the provider doesn't retry\n  res.status(200).send(&quot;ok&quot;);\n\n  switch (event.type) {\n    case &quot;delivered&quot;:\n      await metrics.increment(&quot;email.delivered&quot;, { stream: event.tag });\n      break;\n\n    case &quot;bounced&quot;:\n      await metrics.increment(&quot;email.bounced&quot;, { kind: event.bounceType });\n      if (event.bounceType === &quot;hard&quot;) {\n        await suppressionList.add(event.email); \u002F\u002F never email it again\n      }\n      break;\n\n    case &quot;complained&quot;:\n      await metrics.increment(&quot;email.complained&quot;);\n      await suppressionList.add(event.email);\n      await alerts.page(&quot;Spam complaint received&quot;, event); \u002F\u002F act immediately\n      break;\n\n    case &quot;deferred&quot;:\n      await metrics.increment(&quot;email.deferred&quot;, { reason: event.reason });\n      break;\n  }\n});\n\napp.listen(3000);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Key practices in that handler:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Respond \u003Ccode>200\u003C\u002Fcode> immediately\u003C\u002Fstrong>, then process asynchronously. Slow webhook handlers get retried and pile up.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Auto-suppress hard bounces and complaints.\u003C\u002Fstrong> Sending to a known-bad or complaining address again is the fastest way to wreck your reputation.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Tag every send\u003C\u002Fstrong> with the email type (\u003Ccode>password_reset\u003C\u002Fcode>, \u003Ccode>receipt\u003C\u002Fcode>, etc.) so you can segment your metrics.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Step 2: Verify webhook authenticity\u003C\u002Fh3>\n\u003Cp>Anyone who finds your webhook URL can POST fake events. Verify the signature your provider sends:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import crypto from &quot;crypto&quot;;\n\nfunction verifyWebhook(req, signingSecret) {\n  const signature = req.headers[&quot;x-webhook-signature&quot;];\n  const expected = crypto\n    .createHmac(&quot;sha256&quot;, signingSecret)\n    .update(JSON.stringify(req.body))\n    .digest(&quot;hex&quot;);\n\n  return crypto.timingSafeEqual(\n    Buffer.from(signature),\n    Buffer.from(expected)\n  );\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 3: Store events and compute rolling metrics\u003C\u002Fh3>\n\u003Cp>Persist events to a time-series store or even a simple \u003Ccode>email_events\u003C\u002Fcode> table, then compute rolling rates. A daily delivery-rate query looks like:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">SELECT\n  date_trunc('hour', created_at)      AS bucket,\n  tag                                 AS email_stream,\n  count(*) FILTER (WHERE type = 'sent')      AS sent,\n  count(*) FILTER (WHERE type = 'delivered') AS delivered,\n  count(*) FILTER (WHERE type = 'bounced')   AS bounced,\n  round(\n    100.0 * count(*) FILTER (WHERE type = 'delivered')\n          \u002F nullif(count(*) FILTER (WHERE type = 'sent'), 0),\n    2\n  ) AS delivery_rate_pct\nFROM email_events\nWHERE created_at &gt; now() - interval '24 hours'\nGROUP BY 1, 2\nORDER BY 1 DESC;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 4: Alert on anomalies, not just thresholds\u003C\u002Fh3>\n\u003Cp>Static thresholds catch slow rot but miss sudden failures. Combine both:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Threshold alerts:\u003C\u002Fstrong> delivery rate &lt; 95%, complaint rate &gt; 0.3%, hard bounce rate &gt; 2%.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Anomaly alerts:\u003C\u002Fstrong> delivery rate drops more than X points hour-over-hour, or send volume drops to zero (your email system is \u003Cem>down\u003C\u002Fem> and no errors fired).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Zero-delivery alarm:\u003C\u002Fstrong> if a stream that normally sends has produced no \u003Ccode>delivered\u003C\u002Fcode> events in N minutes, page someone. Silence is the most dangerous failure.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Route critical alerts (complaint spikes, delivery-rate cliffs) to PagerDuty\u002FSlack with the same urgency as a production outage — because they are one.\u003C\u002Fp>\n\u003Ch3>Step 5: Watch authentication and reputation\u003C\u002Fh3>\n\u003Cp>Beyond per-message events, monitor the infrastructure that governs deliverability:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>DMARC aggregate reports\u003C\u002Fstrong> — set up a DMARC record with a reporting address to catch authentication failures and spoofing.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM\u002FSPF validity\u003C\u002Fstrong> — alert if a DKIM key is rotated incorrectly or an SPF record breaks.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocklist monitoring\u003C\u002Fstrong> — check your sending domain\u002FIP against major blocklists (e.g., Spamhaus) on a schedule.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A good transactional email provider surfaces much of this for you, which is the entire point of not running your own SMTP infrastructure.\u003C\u002Fp>\n\u003Ch2>Build vs. Buy: Where Your Monitoring Comes From\u003C\u002Fh2>\n\u003Cp>You have three broad options for email monitoring, with very different cost and effort profiles.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Approach\u003C\u002Fth>\n\u003Cth>What you get\u003C\u002Fth>\n\u003Cth>Effort\u003C\u002Fth>\n\u003Cth>Best for\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Roll your own SMTP + monitoring\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Full control, full responsibility\u003C\u002Ftd>\n\u003Ctd>Very high\u003C\u002Ftd>\n\u003Ctd>Teams with dedicated deliverability engineers\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Provider with built-in monitoring\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Webhooks, dashboards, suppression, reputation tracking out of the box\u003C\u002Ftd>\n\u003Ctd>Low\u003C\u002Ftd>\n\u003Ctd>Almost every SaaS\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Third-party monitoring on top of a basic sender\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Extra analytics layer\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Teams whose provider lacks visibility\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>For the vast majority of SaaS companies, the answer is a transactional email provider that gives you delivery tracking, webhooks, suppression lists, and reputation monitoring as first-class features. Building this yourself means owning IP warm-up, feedback loops, blocklist remediation, and an events pipeline — months of work that adds zero product value. The value of email monitoring is the \u003Cem>visibility\u003C\u002Fem>, not the plumbing.\u003C\u002Fp>\n\u003Ch2>Real-World Example: Catching a Failure Before Users Do\u003C\u002Fh2>\n\u003Cp>Here's how email monitoring plays out in practice for a typical SaaS.\u003C\u002Fp>\n\u003Cp>A team ships a change to their signup form and accidentally removes client-side email validation. Within an hour:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Hard bounce rate\u003C\u002Fstrong> climbs from 0.3% to 4% as malformed and fake addresses flow in.\u003C\u002Fli>\n\u003Cli>The \u003Cstrong>threshold alert\u003C\u002Fstrong> fires in Slack: \"Hard bounce rate &gt; 2% on \u003Ccode>signup_verification\u003C\u002Fcode> stream.\"\u003C\u002Fli>\n\u003Cli>The on-call engineer checks the monitoring dashboard, sees the bounce reasons are all \u003Ccode>invalid recipient\u003C\u002Fcode>, and correlates it with the form deploy 50 minutes earlier.\u003C\u002Fli>\n\u003Cli>They roll back the form change. Bounce rate normalizes within the hour.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Without monitoring, that same scenario plays out very differently: the bounce rate keeps climbing for \u003Cem>days\u003C\u002Fem>, the sending domain's reputation tanks, legitimate verification emails start landing in spam, signups quietly drop, and someone eventually notices the conversion-rate dip a week later — with no idea it was an email problem. Monitoring turned a week-long mystery into a one-hour fix.\u003C\u002Fp>\n\u003Ch2>Common Email Monitoring Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even teams that set up monitoring often undermine it. Watch for these.\u003C\u002Fp>\n\u003Ch3>1. Treating \"sent\" as \"delivered\"\u003C\u002Fh3>\n\u003Cp>The cardinal sin. A 200 from your API means \u003Cem>accepted\u003C\u002Fem>, not \u003Cem>received\u003C\u002Fem>. Always track all the way to \u003Ccode>delivered\u003C\u002Fcode>, and build your delivery-rate metric on that. If you only watch send counts, you are monitoring nothing useful.\u003C\u002Fp>\n\u003Ch3>2. Ignoring soft bounces and deferrals\u003C\u002Fh3>\n\u003Cp>Soft bounces and deferrals look harmless individually, but a sustained spike is an early warning that an ISP is throttling you. Teams that only alert on hard bounces miss the slow-motion reputation problems entirely.\u003C\u002Fp>\n\u003Ch3>3. Not suppressing bounces and complaints automatically\u003C\u002Fh3>\n\u003Cp>Repeatedly emailing an address that hard-bounced or complained is the single fastest way to destroy your reputation. Every bounce\u002Fcomplaint event should automatically add the address to a suppression list. Manual cleanup never keeps up.\u003C\u002Fp>\n\u003Ch3>4. Aggregating everything into one number\u003C\u002Fh3>\n\u003Cp>A 98% overall delivery rate can hide a \u003Ccode>password_reset\u003C\u002Fcode> stream that's at 80% because of one broken template or DNS issue. Always segment monitoring by \u003Cstrong>sending domain\u003C\u002Fstrong> and \u003Cstrong>email type\u003C\u002Fstrong>. Aggregate metrics smooth over exactly the failures you most need to see.\u003C\u002Fp>\n\u003Ch3>5. No alerting — just dashboards\u003C\u002Fh3>\n\u003Cp>A dashboard nobody looks at is not monitoring. If a human has to remember to check it, failures will go unnoticed for days. The whole point is to be \u003Cem>told\u003C\u002Fem> when something breaks. Wire up alerts, route them like incidents, and include a zero-volume alarm so silence doesn't pass for health.\u003C\u002Fp>\n\u003Ch3>6. Forgetting authentication monitoring\u003C\u002Fh3>\n\u003Cp>An expired DKIM key or a broken SPF record after a DNS migration can cause blanket rejection across an ISP overnight. If you only monitor message events and not authentication\u002Freputation, you'll see the symptom (delivery collapse) without the cause. Monitor SPF\u002FDKIM\u002FDMARC validity too.\u003C\u002Fp>\n\u003Ch3>7. Slow or unverified webhook endpoints\u003C\u002Fh3>\n\u003Cp>A webhook handler that does heavy synchronous work will get retried, double-count events, and fall behind. And an unverified endpoint can be poisoned with fake events. Acknowledge fast, process async, and always verify signatures.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>What is transactional email monitoring?\u003C\u002Fh3>\n\u003Cp>Transactional email monitoring is the practice of continuously tracking and alerting on what happens to the operational emails your application sends — password resets, receipts, verification codes, and notifications. It captures delivery, bounce, complaint, and engagement events (usually via webhooks), turns them into health metrics like delivery rate and complaint rate, and alerts your team when something goes wrong. The goal is to catch failures before users do.\u003C\u002Fp>\n\u003Ch3>How is email monitoring different from email delivery tracking?\u003C\u002Fh3>\n\u003Cp>Email delivery tracking follows an \u003Cem>individual\u003C\u002Fem> message through its lifecycle — sent, delivered, bounced. Email monitoring is the broader discipline that aggregates that tracking data across all your mail, computes rolling metrics, watches for anomalies, correlates with reputation signals, and alerts on problems. Delivery tracking is a building block; monitoring is the system you build on top of it.\u003C\u002Fp>\n\u003Ch3>What email metrics should a SaaS monitor?\u003C\u002Fh3>\n\u003Cp>At minimum: \u003Cstrong>delivery rate\u003C\u002Fstrong> (delivered ÷ sent, target &gt; 98%), \u003Cstrong>hard bounce rate\u003C\u002Fstrong> (target &lt; 0.5%, alert &gt; 2%), \u003Cstrong>complaint rate\u003C\u002Fstrong> (target &lt; 0.1%, alert &gt; 0.3%), and \u003Cstrong>deferral\u002Fsoft-bounce trends\u003C\u002Fstrong>. Track these per sending domain and per email type rather than as one aggregate number, because problems often isolate to a single stream.\u003C\u002Fp>\n\u003Ch3>How do I know if my transactional emails are being delivered?\u003C\u002Fh3>\n\u003Cp>Set up webhooks from your email provider to receive \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode> events, store them, and compute your delivery rate. A delivery rate consistently above ~98% with low bounce and complaint rates means you're healthy. If you're only seeing \"sent\" confirmations and no delivery events, you have no real visibility — accepting a message is not the same as delivering it.\u003C\u002Fp>\n\u003Ch3>What is a good delivery rate for transactional email?\u003C\u002Fh3>\n\u003Cp>For transactional email, aim for a delivery rate above \u003Cstrong>98%\u003C\u002Fstrong>. Because transactional mail goes to users who explicitly triggered it (and to addresses they entered themselves), it should deliver more reliably than marketing mail. A delivery rate dropping below 95% signals a reputation, authentication, or list-quality problem that needs investigation.\u003C\u002Fp>\n\u003Ch3>Do I need to build my own email monitoring system?\u003C\u002Fh3>\n\u003Cp>Usually not. Building your own means owning the entire events pipeline, suppression logic, IP warm-up, feedback loops, and blocklist remediation — months of deliverability engineering that adds no product value. Most SaaS teams are far better served by a transactional email provider that includes delivery tracking, webhooks, suppression lists, and reputation monitoring out of the box. The value is the visibility, not the infrastructure.\u003C\u002Fp>\n\u003Ch3>Why do transactional emails fail silently?\u003C\u002Fh3>\n\u003Cp>Because your responsibility ends when your provider accepts the message, but delivery is decided downstream and asynchronously — by the recipient's mail server, spam filters, greylisting, and reputation checks. None of those send an error back to your application code. A message can be accepted with a \u003Ccode>250 OK\u003C\u002Fcode> and then quarantined, deferred for hours, or rejected by an ISP without any signal reaching your logs. Webhook-based monitoring is the only way to surface those downstream failures.\u003C\u002Fp>\n\u003Ch3>How often should I check my email monitoring?\u003C\u002Fh3>\n\u003Cp>You shouldn't have to check it manually at all — that's the point of alerting. Configure threshold and anomaly alerts (delivery-rate cliffs, complaint spikes, zero-volume alarms) routed to Slack or PagerDuty so the system tells you when something breaks. Reserve dashboard review for weekly trend analysis and post-incident investigation, not for catching live failures.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Transactional emails sit on the most important paths in your SaaS — activation, authentication, billing — and they fail silently by default. Your provider's \u003Ccode>250 OK\u003C\u002Fcode> tells you nothing about whether the user actually got the message. Email monitoring closes that visibility gap: it tracks every message through delivery, captures bounces and complaints via webhooks, turns raw events into health metrics, and alerts you the moment something breaks.\u003C\u002Fp>\n\u003Cp>The teams that monitor catch a broken form, an expired DKIM key, or a creeping complaint rate within the hour. The teams that don't discover the problem a week later as a mysterious dip in conversions — after the reputation damage is already done. The difference isn't sophistication; it's whether you're listening to the half of the email lifecycle that happens after your code runs.\u003C\u002Fp>\n\u003Cp>Start small this week: receive delivery webhooks, auto-suppress bounces and complaints, compute a per-stream delivery rate, and set three alerts (delivery rate, complaint rate, zero-volume). That alone puts you ahead of most SaaS products and protects your most critical user flows from failing in silence.\u003C\u002Fp>\n\u003Ch2>Send and Monitor Transactional Email with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers and SaaS companies that treats monitoring as a first-class feature, not an afterthought. You get real-time delivery tracking, webhook events for every lifecycle stage (delivered, bounced, complained, deferred), automatic suppression of bounces and complaints, and dashboards for delivery rate, bounce rate, and complaint rate — segmented by domain and email type — so you can see problems before your users do.\u003C\u002Fp>\n\u003Cp>Authentication (SPF, DKIM, DMARC) and reputation monitoring come built in, so you're not stitching together blocklist checks and DNS alerts yourself. And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, international founders and crypto-native teams can pay without the friction of traditional payment rails or card requirements.\u003C\u002Fp>\n\u003Cp>Stop flying blind on your most critical emails. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending and monitoring transactional email with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","089665e9-d3bc-494d-b14f-b425825ec043","2026-06-24T18:28:18.033596+03:00","2026-07-15T23:41:59.704958+03:00","Why Every SaaS Needs Transactional Email Monitoring","Email monitoring catches delivery failures before users notice. Learn what to track, how to set it up, and why SaaS apps can't run blind on transactional mail.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_089665e9-d3bc-494d-b14f-b425825ec043\u002FWhy_Every_SaaS_Needs_Transactional_Emai_S1esK0U.png","2026-07-14T09:00:00+03:00",[149,150],"email monitoring","email delivery tracking",{"id":152,"body":153,"uuid":154,"created_at":155,"updated_at":156,"brand":13,"header":157,"short_body":158,"image":159,"published":17,"published_at":160,"tags":161},12,"\u003Cp>If your transactional emails land in spam, get silently dropped, or trigger \"via\" warnings in Gmail, the root cause is almost always email authentication. \u003Cstrong>SPF, DKIM, and DMARC\u003C\u002Fstrong> are the three DNS-based standards that let receiving mail servers verify that a message claiming to be from \u003Ccode>yourdomain.com\u003C\u002Fcode> actually came from a server you authorized — and that it wasn't tampered with in transit. Get them right and your password resets, receipts, and verification codes reach the inbox. Get them wrong and a single misconfigured record can quietly destroy your deliverability.\u003C\u002Fp>\n\u003Cp>This guide explains how SPF, DKIM, and DMARC work together, shows you the exact DNS records to publish, and walks through the mistakes that trip up most engineering teams. It's written for SaaS founders, software engineers, and CTOs who need email to \u003Cem>just work\u003C\u002Fem> — without becoming part-time email administrators.\u003C\u002Fp>\n\u003Ch2>What Is Email Authentication?\u003C\u002Fh2>\n\u003Cp>Email authentication is a set of protocols that prove an email is legitimately from the domain it claims to be from. The original SMTP protocol, designed in the early 1980s, has \u003Cstrong>no built-in identity verification\u003C\u002Fstrong> — anyone can connect to a mail server and claim to be \u003Ccode>support@yourbank.com\u003C\u002Fcode>. That open design is why phishing and spoofing remain the most common attack vectors today.\u003C\u002Fp>\n\u003Cp>The three core standards solve this problem from different angles:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Standard\u003C\u002Fth>\n\u003Cth>What it verifies\u003C\u002Fth>\n\u003Cth>Where it's published\u003C\u002Fth>\n\u003Cth>Year standardized\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>SPF\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Which servers are allowed to send for your domain\u003C\u002Ftd>\n\u003Ctd>DNS TXT record\u003C\u002Ftd>\n\u003Ctd>2014 (RFC 7208)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>DKIM\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>That the message wasn't altered and came from your domain\u003C\u002Ftd>\n\u003Ctd>DNS TXT record + email header signature\u003C\u002Ftd>\n\u003Ctd>2011 (RFC 6376)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>DMARC\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>What receivers should do when SPF\u002FDKIM fail, plus reporting\u003C\u002Ftd>\n\u003Ctd>DNS TXT record\u003C\u002Ftd>\n\u003Ctd>2015 (RFC 7489)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Think of it like a sealed, signed letter:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF\u003C\u002Fstrong> is the approved list of post offices allowed to mail on your behalf.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM\u003C\u002Fstrong> is the wax seal that proves the letter wasn't opened and resealed.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC\u003C\u002Fstrong> is the instruction to the recipient: \"if the post office isn't on my list \u003Cem>and\u003C\u002Fem> the seal is broken, reject the letter — and send me a report either way.\"\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>None of these is optional anymore. Since February 2024, \u003Cstrong>Google and Yahoo require SPF, DKIM, and DMARC\u003C\u002Fstrong> for bulk senders, and they increasingly scrutinize transactional senders too. Microsoft began enforcing similar requirements for high-volume senders in 2025.\u003C\u002Fp>\n\u003Ch2>How SPF Works (Sender Policy Framework)\u003C\u002Fh2>\n\u003Cp>SPF lets you publish a list of IP addresses and servers authorized to send email on behalf of your domain. When a receiving server gets a message, it looks up the SPF record of the domain in the \u003Ccode>Return-Path\u003C\u002Fcode> (also called the envelope sender or \u003Ccode>MAIL FROM\u003C\u002Fcode>) and checks whether the connecting IP is on the approved list.\u003C\u002Fp>\n\u003Ch3>The SPF DNS record\u003C\u002Fh3>\n\u003Cp>An SPF record is a single DNS \u003Ccode>TXT\u003C\u002Fcode> record on your sending domain. Here's a typical one:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">yourdomain.com.  IN  TXT  &quot;v=spf1 include:_spf.postwing.app include:_spf.google.com -all&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Breaking that down:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>v=spf1\u003C\u002Fcode> — declares this is an SPF version 1 record.\u003C\u002Fli>\n\u003Cli>\u003Ccode>include:_spf.postwing.app\u003C\u002Fcode> — authorizes Postwing's sending infrastructure.\u003C\u002Fli>\n\u003Cli>\u003Ccode>include:_spf.google.com\u003C\u002Fcode> — authorizes Google Workspace to send (for your regular business mail).\u003C\u002Fli>\n\u003Cli>\u003Ccode>-all\u003C\u002Fcode> — the \u003Cstrong>qualifier\u003C\u002Fstrong> that tells receivers what to do with everything else.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>SPF qualifiers explained\u003C\u002Fh3>\n\u003Cp>The trailing mechanism is the most important and most misunderstood part of SPF:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Qualifier\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Behavior on failure\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>-all\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Hard fail\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Reject unauthorized senders. Strongest.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>~all\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Soft fail\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Accept but mark suspicious. Common during rollout.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>?all\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Neutral\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>No opinion. Provides almost no protection.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>+all\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Pass all\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Allows \u003Cem>anyone\u003C\u002Fem> to send. Never use this.\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Start with \u003Ccode>~all\u003C\u002Fcode> while you confirm every legitimate sender is included, then tighten to \u003Ccode>-all\u003C\u002Fcode> once you're confident.\u003C\u002Fp>\n\u003Ch3>The SPF 10-lookup limit\u003C\u002Fh3>\n\u003Cp>SPF has a hard rule that catches almost every growing team: \u003Cstrong>a maximum of 10 DNS lookups\u003C\u002Fstrong> per evaluation. Each \u003Ccode>include\u003C\u002Fcode>, \u003Ccode>a\u003C\u002Fcode>, \u003Ccode>mx\u003C\u002Fcode>, \u003Ccode>ptr\u003C\u002Fcode>, and \u003Ccode>redirect\u003C\u002Fcode> mechanism counts. Exceed 10 and SPF returns a \u003Ccode>permerror\u003C\u002Fcode>, which most receivers treat as a failure — silently breaking authentication for \u003Cem>all\u003C\u002Fem> your mail.\u003C\u002Fp>\n\u003Cp>A common cause is stacking too many providers:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\"># This can easily blow past 10 lookups\n&quot;v=spf1 include:_spf.google.com include:servers.mcsv.net include:sendgrid.net include:_spf.salesforce.com include:mail.zendesk.com -all&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>To stay under the limit, use \u003Cstrong>SPF flattening\u003C\u002Fstrong> (replacing includes with their resolved IP ranges) or consolidate senders. A well-designed provider like Postwing publishes a single, lookup-efficient \u003Ccode>include\u003C\u002Fcode> so you don't burn your budget on one vendor.\u003C\u002Fp>\n\u003Ch3>SPF's weakness: it breaks on forwarding\u003C\u002Fh3>\n\u003Cp>SPF checks the envelope sender's IP. When an email is \u003Cstrong>forwarded\u003C\u002Fstrong>, the forwarding server becomes the new sending IP — which isn't on your SPF list — so SPF fails. This is why SPF alone is not enough, and why DKIM and DMARC exist.\u003C\u002Fp>\n\u003Ch2>How DKIM Works (DomainKeys Identified Mail)\u003C\u002Fh2>\n\u003Cp>DKIM uses public-key cryptography to attach a tamper-proof digital signature to every outgoing message. Unlike SPF, which checks the \u003Cem>connection\u003C\u002Fem>, DKIM verifies the \u003Cem>content and headers\u003C\u002Fem> of the email itself — so it survives forwarding.\u003C\u002Fp>\n\u003Ch3>The signing and verification flow\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Your sending server (or provider) generates a \u003Cstrong>private\u002Fpublic key pair\u003C\u002Fstrong>.\u003C\u002Fli>\n\u003Cli>The \u003Cstrong>public key\u003C\u002Fstrong> is published as a DNS TXT record at a \"selector\" subdomain.\u003C\u002Fli>\n\u003Cli>For each outgoing email, the server hashes selected headers and the body, then signs that hash with the \u003Cstrong>private key\u003C\u002Fstrong>, adding a \u003Ccode>DKIM-Signature\u003C\u002Fcode> header.\u003C\u002Fli>\n\u003Cli>The receiving server reads the signature, fetches your public key from DNS, and verifies the signature mathematically.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>If the body or signed headers were altered in transit, verification fails.\u003C\u002Fp>\n\u003Ch3>The DKIM DNS record\u003C\u002Fh3>\n\u003Cp>The public key lives at \u003Ccode>&lt;selector&gt;._domainkey.yourdomain.com\u003C\u002Fcode>. The selector lets you run multiple keys (e.g., per provider or for key rotation):\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">pw1._domainkey.yourdomain.com.  IN  TXT  &quot;v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC7...&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>And the resulting header on a signed message looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>DKIM-Signature: v=1; a=rsa-sha256; c=relaxed\u002Frelaxed;\n  d=yourdomain.com; s=pw1;\n  h=from:to:subject:date:message-id;\n  bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\n  b=AuUoFEfDxTDkHlLXSZEpZj79LICXz...\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Key fields:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>d=\u003C\u002Fcode> — the signing \u003Cstrong>domain\u003C\u002Fstrong>.\u003C\u002Fli>\n\u003Cli>\u003Ccode>s=\u003C\u002Fcode> — the \u003Cstrong>selector\u003C\u002Fstrong> pointing to the public key.\u003C\u002Fli>\n\u003Cli>\u003Ccode>bh=\u003C\u002Fcode> — the \u003Cstrong>body hash\u003C\u002Fstrong>.\u003C\u002Fli>\n\u003Cli>\u003Ccode>b=\u003C\u002Fcode> — the actual cryptographic signature.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>DKIM key length and rotation\u003C\u002Fh3>\n\u003Cp>Use \u003Cstrong>2048-bit RSA keys\u003C\u002Fstrong>, not 1024-bit. Google now flags messages signed with short keys, and 1024-bit keys are considered weak. Rotate keys periodically (every 6–12 months is a reasonable cadence) by publishing a new selector before retiring the old one — this avoids any signing gap.\u003C\u002Fp>\n\u003Ch2>How DMARC Works (Domain-based Message Authentication)\u003C\u002Fh2>\n\u003Cp>DMARC ties SPF and DKIM together and adds two critical capabilities: a \u003Cstrong>published policy\u003C\u002Fstrong> telling receivers what to do with failures, and \u003Cstrong>aggregate reporting\u003C\u002Fstrong> so you can see who's sending mail as your domain.\u003C\u002Fp>\n\u003Ch3>Alignment: the concept that makes DMARC work\u003C\u002Fh3>\n\u003Cp>DMARC introduces the concept of \u003Cstrong>alignment\u003C\u002Fstrong>. It's not enough for SPF or DKIM to merely \u003Cem>pass\u003C\u002Fem> — the domain they authenticate must \u003Cem>align\u003C\u002Fem> with the domain in the visible \u003Ccode>From:\u003C\u002Fcode> header that users actually see.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF alignment\u003C\u002Fstrong>: the \u003Ccode>Return-Path\u003C\u002Fcode> domain matches the \u003Ccode>From:\u003C\u002Fcode> domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM alignment\u003C\u002Fstrong>: the DKIM \u003Ccode>d=\u003C\u002Fcode> domain matches the \u003Ccode>From:\u003C\u002Fcode> domain.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>DMARC passes if \u003Cstrong>at least one\u003C\u002Fstrong> of SPF or DKIM passes \u003Cem>and\u003C\u002Fem> is aligned. This is why a forwarded email can still pass DMARC: even if SPF breaks, an aligned DKIM signature survives.\u003C\u002Fp>\n\u003Cp>Alignment can be \u003Cstrong>strict\u003C\u002Fstrong> (exact domain match) or \u003Cstrong>relaxed\u003C\u002Fstrong> (organizational domain match — e.g., \u003Ccode>mail.yourdomain.com\u003C\u002Fcode> aligns with \u003Ccode>yourdomain.com\u003C\u002Fcode>). Relaxed is the default and the right choice for most teams.\u003C\u002Fp>\n\u003Ch3>The DMARC DNS record\u003C\u002Fh3>\n\u003Cp>DMARC is published at \u003Ccode>_dmarc.yourdomain.com\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">_dmarc.yourdomain.com.  IN  TXT  &quot;v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com; pct=100; adkim=r; aspf=r; fo=1&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Tag\u003C\u002Fth>\n\u003Cth>Purpose\u003C\u002Fth>\n\u003Cth>Common values\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>p\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Policy for failures\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>none\u003C\u002Fcode>, \u003Ccode>quarantine\u003C\u002Fcode>, \u003Ccode>reject\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>rua\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Address for aggregate reports\u003C\u002Ftd>\n\u003Ctd>a mailbox you monitor\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>ruf\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Address for forensic reports\u003C\u002Ftd>\n\u003Ctd>optional, privacy-sensitive\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>pct\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Percentage of mail the policy applies to\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>1\u003C\u002Fcode>–\u003Ccode>100\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>adkim\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>DKIM alignment mode\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>r\u003C\u002Fcode> (relaxed) or \u003Ccode>s\u003C\u002Fcode> (strict)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>aspf\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>SPF alignment mode\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>r\u003C\u002Fcode> or \u003Ccode>s\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>sp\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Policy for subdomains\u003C\u002Ftd>\n\u003Ctd>inherits \u003Ccode>p\u003C\u002Fcode> if omitted\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>The three DMARC policies\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>\u003Ccode>p=none\u003C\u002Fcode>\u003C\u002Fstrong> — Monitor only. Receivers take no action but send you reports. Always start here.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\u003Ccode>p=quarantine\u003C\u002Fcode>\u003C\u002Fstrong> — Send failing mail to spam\u002Fjunk.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\u003Ccode>p=reject\u003C\u002Fcode>\u003C\u002Fstrong> — Reject failing mail outright. This is the goal.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Roughly \u003Cstrong>70% of domains\u003C\u002Fstrong> that publish a DMARC record never move past \u003Ccode>p=none\u003C\u002Fcode>, which means they get reporting but no actual protection against spoofing. Your target should be \u003Ccode>p=reject\u003C\u002Fcode> once your reports show all legitimate mail passing.\u003C\u002Fp>\n\u003Ch2>Step-by-Step: Setting Up SPF, DKIM, and DMARC\u003C\u002Fh2>\n\u003Cp>Here's a practical rollout sequence that won't break your existing mail flow.\u003C\u002Fp>\n\u003Ch3>1. Inventory every service that sends as your domain\u003C\u002Fh3>\n\u003Cp>Before publishing anything, list every sender: your email provider (Google\u002FMicrosoft), your transactional provider (Postwing), your CRM, your support desk, your marketing tool, your billing platform. Each one needs to be authorized.\u003C\u002Fp>\n\u003Ch3>2. Publish SPF\u003C\u002Fh3>\n\u003Cp>Combine all authorized senders into one record and start with a soft fail:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">yourdomain.com.  IN  TXT  &quot;v=spf1 include:_spf.postwing.app include:_spf.google.com ~all&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Verify it resolves and stays under 10 lookups using a checker tool.\u003C\u002Fp>\n\u003Ch3>3. Enable DKIM\u003C\u002Fh3>\n\u003Cp>Generate keys in your provider's dashboard. Postwing, for example, gives you the exact TXT records to paste. Publish each provider's selector:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">pw1._domainkey.yourdomain.com.  IN  TXT  &quot;v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3...&quot;\ngoogle._domainkey.yourdomain.com.  IN  TXT  &quot;v=DKIM1; k=rsa; p=MIIBIjANBgkq...&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Send a test email to a Gmail account, open \u003Cstrong>Show original\u003C\u002Fstrong>, and confirm \u003Ccode>DKIM: 'PASS'\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>4. Publish DMARC in monitoring mode\u003C\u002Fh3>\n\u003Cp>Start with \u003Ccode>p=none\u003C\u002Fcode> so nothing gets blocked while you observe:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">_dmarc.yourdomain.com.  IN  TXT  &quot;v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com; adkim=r; aspf=r&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>5. Read your reports, then enforce\u003C\u002Fh3>\n\u003Cp>DMARC aggregate (\u003Ccode>rua\u003C\u002Fcode>) reports arrive as XML, typically daily. Use a DMARC monitoring service to make them readable. Once reports show 100% of your legitimate mail passing with alignment, ramp up:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>p=none  →  p=quarantine; pct=25  →  p=quarantine; pct=100  →  p=reject\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Give each stage a week or two. This gradual rollout is the single most reliable way to reach \u003Ccode>p=reject\u003C\u002Fcode> without losing real email.\u003C\u002Fp>\n\u003Ch2>How to Verify Authentication Passes\u003C\u002Fh2>\n\u003Cp>The fastest check is the \u003Cstrong>Show original\u003C\u002Fstrong> view in Gmail (or \u003Cstrong>View message source\u003C\u002Fstrong> in Outlook). Look for:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>SPF:    PASS with IP 203.0.113.10\nDKIM:   'PASS' with domain yourdomain.com\nDMARC:  'PASS'\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>For command-line verification, you can inspect the published records directly:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># Check SPF\ndig +short TXT yourdomain.com\n\n# Check DKIM for a given selector\ndig +short TXT pw1._domainkey.yourdomain.com\n\n# Check DMARC\ndig +short TXT _dmarc.yourdomain.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>You can also send a message to a free authentication-check mailbox (such as the ones offered by mail-tester or Google's Postmaster Tools) to get a full pass\u002Ffail breakdown.\u003C\u002Fp>\n\u003Ch2>Common Mistakes With SPF, DKIM, and DMARC\u003C\u002Fh2>\n\u003Cp>These are the errors that quietly wreck deliverability — even for teams that \u003Cem>think\u003C\u002Fem> they've set everything up correctly.\u003C\u002Fp>\n\u003Ch3>Publishing multiple SPF records\u003C\u002Fh3>\n\u003Cp>A domain may have \u003Cstrong>only one\u003C\u002Fstrong> SPF record. Two \u003Ccode>v=spf1\u003C\u002Fcode> TXT records cause a \u003Ccode>permerror\u003C\u002Fcode> and SPF fails entirely. If you add a new provider, merge its \u003Ccode>include\u003C\u002Fcode> into your existing record — never create a second one.\u003C\u002Fp>\n\u003Ch3>Exceeding the 10-lookup limit\u003C\u002Fh3>\n\u003Cp>As covered above, stacking too many \u003Ccode>include:\u003C\u002Fcode> mechanisms breaks SPF silently. Audit your lookup count whenever you add a sender.\u003C\u002Fp>\n\u003Ch3>Using \u003Ccode>+all\u003C\u002Fcode> or \u003Ccode>?all\u003C\u002Fcode>\u003C\u002Fh3>\n\u003Cp>\u003Ccode>+all\u003C\u002Fcode> authorizes the entire internet to send as you. \u003Ccode>?all\u003C\u002Fcode> provides no protection. Always end with \u003Ccode>~all\u003C\u002Fcode> (during rollout) or \u003Ccode>-all\u003C\u002Fcode> (in production).\u003C\u002Fp>\n\u003Ch3>Forgetting subdomains\u003C\u002Fh3>\n\u003Cp>If you send transactional mail from \u003Ccode>mail.yourdomain.com\u003C\u002Fcode> but only authenticate \u003Ccode>yourdomain.com\u003C\u002Fcode>, the subdomain mail fails. Set the \u003Ccode>sp\u003C\u002Fcode> tag in DMARC and publish records for each sending subdomain.\u003C\u002Fp>\n\u003Ch3>Jumping straight to \u003Ccode>p=reject\u003C\u002Fcode>\u003C\u002Fh3>\n\u003Cp>Going from no DMARC to \u003Ccode>p=reject\u003C\u002Fcode> without monitoring will reject legitimate mail from senders you forgot to authorize. Always start at \u003Ccode>p=none\u003C\u002Fcode> and read reports first.\u003C\u002Fp>\n\u003Ch3>Ignoring DMARC reports\u003C\u002Fh3>\n\u003Cp>The reports exist for a reason: they reveal shadow IT (that marketing tool someone signed up for), spoofing attempts, and misconfigured senders. A \u003Ccode>p=none\u003C\u002Fcode> record you never look at protects nothing.\u003C\u002Fp>\n\u003Ch3>Mismatched From and Return-Path domains\u003C\u002Fh3>\n\u003Cp>Many \"set it and forget it\" failures come from alignment, not authentication. SPF can pass while DMARC fails because the \u003Ccode>Return-Path\u003C\u002Fcode> domain doesn't match the visible \u003Ccode>From:\u003C\u002Fcode> domain. A good provider lets you use a custom Return-Path (a CNAME) on your own domain to fix this.\u003C\u002Fp>\n\u003Ch3>Letting DKIM keys go stale\u003C\u002Fh3>\n\u003Cp>A 1024-bit key from years ago, or a key your provider rotated without you updating DNS, causes signature failures. Verify your selector still resolves to a current key after any provider change.\u003C\u002Fp>\n\u003Ch2>Why This Matters for SaaS and Transactional Email\u003C\u002Fh2>\n\u003Cp>For a SaaS company, transactional email \u003Cem>is\u003C\u002Fem> part of the product. A password reset that never arrives is a locked-out customer. A verification code in spam is a failed signup. An invoice that bounces is delayed revenue.\u003C\u002Fp>\n\u003Cp>The data backs this up:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Domains with a \u003Ccode>p=reject\u003C\u002Fcode> DMARC policy see measurably \u003Cstrong>higher inbox placement\u003C\u002Fstrong> because mailbox providers treat authenticated senders as more trustworthy.\u003C\u002Fli>\n\u003Cli>BIMI (Brand Indicators for Message Identification) — the standard that displays your logo next to authenticated emails in Gmail and Apple Mail — \u003Cstrong>requires DMARC at \u003Ccode>quarantine\u003C\u002Fcode> or \u003Ccode>reject\u003C\u002Fcode>\u003C\u002Fstrong>. No DMARC, no logo, no trust signal.\u003C\u002Fli>\n\u003Cli>Phishing using your domain damages your sender reputation even if \u003Cem>you\u003C\u002Fem> never sent the message. DMARC enforcement is the only thing that stops attackers from spoofing your brand.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>This is also where your provider choice matters. Configuring SPF, DKIM, and DMARC correctly across multiple vendors is genuinely hard. A platform built for transactional email should hand you copy-paste DNS records, run on lookup-efficient infrastructure, and verify alignment for you — so authentication is a one-time setup, not an ongoing operations burden.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>Do I need all three of SPF, DKIM, and DMARC?\u003C\u002Fh3>\n\u003Cp>Yes. SPF and DKIM each authenticate one aspect of an email, and each has gaps (SPF breaks on forwarding; DKIM doesn't say what to do on failure). DMARC ties them together, enforces alignment, and gives you reporting. Modern mailbox providers like Gmail and Yahoo now \u003Cem>require\u003C\u002Fem> all three for reliable delivery.\u003C\u002Fp>\n\u003Ch3>What's the difference between SPF and DKIM?\u003C\u002Fh3>\n\u003Cp>SPF authorizes which \u003Cstrong>servers\u002FIPs\u003C\u002Fstrong> can send for your domain by checking the connecting IP against a DNS list. DKIM cryptographically \u003Cstrong>signs the message content\u003C\u002Fstrong> so receivers can verify it wasn't altered and genuinely came from your domain. SPF checks the connection; DKIM checks the message. DKIM survives forwarding, SPF does not.\u003C\u002Fp>\n\u003Ch3>What happens if I don't set up email authentication?\u003C\u002Fh3>\n\u003Cp>Your transactional emails are far more likely to land in spam or be rejected outright — especially at Gmail, Yahoo, and Outlook, which now enforce authentication for senders. You also leave your domain open to spoofing and phishing, which harms your brand and sender reputation. Without DMARC, you can't display a verified logo (BIMI) either.\u003C\u002Fp>\n\u003Ch3>How long does it take for SPF, DKIM, and DMARC to work?\u003C\u002Fh3>\n\u003Cp>The DNS records themselves take effect after propagation — usually minutes to a few hours depending on your TTL. The \u003Cem>full rollout\u003C\u002Fem> to \u003Ccode>p=reject\u003C\u002Fcode>, however, should take a few weeks, because you need to read DMARC reports and confirm all legitimate senders pass before enforcing a strict policy.\u003C\u002Fp>\n\u003Ch3>Can SPF, DKIM, and DMARC stop all spoofing?\u003C\u002Fh3>\n\u003Cp>DMARC at \u003Ccode>p=reject\u003C\u002Fcode> stops spoofing that uses your exact domain in the \u003Ccode>From:\u003C\u002Fcode> header — the most common and damaging type. It cannot stop \u003Cstrong>lookalike domains\u003C\u002Fstrong> (e.g., \u003Ccode>yourdomaiin.com\u003C\u002Fcode>) or display-name spoofing, which require separate brand-protection monitoring. Authentication is necessary but not sufficient on its own.\u003C\u002Fp>\n\u003Ch3>What is a DMARC policy of \u003Ccode>p=none\u003C\u002Fcode>, and is it enough?\u003C\u002Fh3>\n\u003Cp>\u003Ccode>p=none\u003C\u002Fcode> is monitor-only: receivers report on failures but take no action. It's the correct \u003Cem>starting\u003C\u002Fem> point because it lets you see all your sending sources without risking real mail. It is \u003Cstrong>not\u003C\u002Fstrong> sufficient long-term — it offers no protection against spoofing. Your goal is to progress to \u003Ccode>p=quarantine\u003C\u002Fcode> and then \u003Ccode>p=reject\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>Why does my email pass SPF but fail DMARC?\u003C\u002Fh3>\n\u003Cp>This is almost always an \u003Cstrong>alignment\u003C\u002Fstrong> problem. DMARC requires the SPF-authenticated \u003Ccode>Return-Path\u003C\u002Fcode> domain to match the visible \u003Ccode>From:\u003C\u002Fcode> domain (relaxed or strict). If your provider sends with their own Return-Path domain instead of yours, SPF passes but doesn't align — so DMARC fails. Using a custom Return-Path on your own domain, or relying on an aligned DKIM signature, fixes it.\u003C\u002Fp>\n\u003Ch3>How many DNS lookups does SPF allow?\u003C\u002Fh3>\n\u003Cp>SPF permits a maximum of \u003Cstrong>10 DNS lookups\u003C\u002Fstrong> per evaluation. Each \u003Ccode>include\u003C\u002Fcode>, \u003Ccode>a\u003C\u002Fcode>, \u003Ccode>mx\u003C\u002Fcode>, \u003Ccode>ptr\u003C\u002Fcode>, and \u003Ccode>redirect\u003C\u002Fcode> mechanism counts toward the limit. Exceeding it produces a \u003Ccode>permerror\u003C\u002Fcode>, which most receivers treat as a failure. Use SPF flattening or consolidate senders to stay within budget.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Email authentication isn't a checkbox — it's the foundation of transactional email deliverability. \u003Cstrong>SPF\u003C\u002Fstrong> says which servers may send for you, \u003Cstrong>DKIM\u003C\u002Fstrong> proves your messages weren't tampered with, and \u003Cstrong>DMARC\u003C\u002Fstrong> enforces both and tells you who's sending as your domain. Together they're the difference between a password reset that arrives in two seconds and one that vanishes into spam.\u003C\u002Fp>\n\u003Cp>The path is straightforward: inventory your senders, publish a single clean SPF record, enable 2048-bit DKIM signing, and roll DMARC from \u003Ccode>p=none\u003C\u002Fcode> to \u003Ccode>p=reject\u003C\u002Fcode> while reading your reports. The most common failures — multiple SPF records, blown lookup limits, alignment mismatches, and stale keys — are all avoidable once you know what to look for.\u003C\u002Fp>\n\u003Ch2>Send Authenticated Email From Day One With Postwing\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Postwing\u003C\u002Fstrong> is a transactional email platform built for developers and SaaS teams who want authentication done right without the operations overhead. When you add your domain, Postwing generates \u003Cstrong>copy-paste SPF, DKIM, and DMARC records\u003C\u002Fstrong>, runs on lookup-efficient infrastructure that won't blow your SPF budget, and verifies alignment so your mail passes DMARC the first time. Custom Return-Path support keeps SPF aligned to \u003Cem>your\u003C\u002Fem> domain, and 2048-bit DKIM signing is on by default.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can spin up production-grade, fully authenticated transactional email without a credit card or a procurement cycle — just connect your wallet and start sending.\u003C\u002Fp>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending authenticated email with Postwing →\u003C\u002Fa>\u003C\u002Fp>","fff171ff-7db6-4819-a366-1de2e7705479","2026-06-24T18:28:18.027039+03:00","2026-07-16T00:01:53.823909+03:00","How Email Authentication Works (SPF, DKIM, DMARC)","Learn how SPF, DKIM, and DMARC email authentication works, with real DNS records, setup steps, and fixes for the mistakes that send mail to spam.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_fff171ff-7db6-4819-a366-1de2e7705479\u002F4f150911-437e-402d-b2af-f6e8df787493.png","2026-07-10T09:00:00+03:00",[162,163],"spf dkim dmarc","email authentication",{"id":165,"body":166,"uuid":167,"created_at":168,"updated_at":169,"brand":13,"header":170,"short_body":171,"image":172,"published":17,"published_at":173,"tags":174},11,"\u003Cp>If your password resets, receipts, and verification links don't reach the inbox, your product doesn't work — full stop. This email deliverability checklist gives SaaS startups a concrete, prioritized path to get transactional messages into the inbox reliably, from DNS authentication to ongoing reputation monitoring. Use it as a one-time setup guide and as a recurring audit, because deliverability is never \"done.\"\u003C\u002Fp>\n\u003Cp>Deliverability is the percentage of sent emails that actually land in the recipient's inbox (not spam, not blocked, not silently dropped). For a SaaS company, a 2% drop in inbox placement on signup verification emails translates directly into failed activations and lost revenue. The good news: most deliverability problems are predictable, and almost all are fixable with the steps below.\u003C\u002Fp>\n\u003Cp>This guide is written for founders, software engineers, and CTOs who need to \u003Cstrong>improve email delivery\u003C\u002Fstrong> without becoming full-time deliverability specialists. Each section is actionable, includes examples or code where useful, and ends with the mistakes that quietly tank inbox placement.\u003C\u002Fp>\n\u003Ch2>What \"Deliverability\" Actually Means\u003C\u002Fh2>\n\u003Cp>Before the checklist, align on terminology, because vendors blur these on purpose.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Delivery rate\u003C\u002Fstrong> — the email was accepted by the receiving mail server. It does \u003Cem>not\u003C\u002Fem> mean the inbox.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Inbox placement rate\u003C\u002Fstrong> — the email landed in the primary inbox (or at least a visible folder), not spam. This is the number that matters.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bounce rate\u003C\u002Fstrong> — the receiving server rejected the message. Split into \u003Cem>hard\u003C\u002Fem> (permanent, e.g. address doesn't exist) and \u003Cem>soft\u003C\u002Fem> (temporary, e.g. mailbox full).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Complaint rate\u003C\u002Fstrong> — recipients marked you as spam. Gmail and Yahoo expect this under \u003Cstrong>0.3%\u003C\u002Fstrong>, ideally under 0.1%.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A provider showing a \"99% delivery rate\" can still have terrible inbox placement. Optimize for inbox placement and complaint rate, not the vanity delivery number.\u003C\u002Fp>\n\u003Ch2>The Email Deliverability Checklist (Quick Reference)\u003C\u002Fh2>\n\u003Cp>Here's the full checklist at a glance. The rest of the article explains each item in depth.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>#\u003C\u002Fth>\n\u003Cth>Checklist item\u003C\u002Fth>\n\u003Cth>Priority\u003C\u002Fth>\n\u003Cth>Frequency\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1\u003C\u002Ftd>\n\u003Ctd>Use a dedicated sending domain\u002Fsubdomain\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>2\u003C\u002Ftd>\n\u003Ctd>Configure SPF\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3\u003C\u002Ftd>\n\u003Ctd>Configure DKIM\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>4\u003C\u002Ftd>\n\u003Ctd>Configure DMARC\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>5\u003C\u002Ftd>\n\u003Ctd>Set up reverse DNS (PTR) \u002F let your ESP handle it\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>6\u003C\u002Ftd>\n\u003Ctd>Separate transactional and marketing streams\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>7\u003C\u002Ftd>\n\u003Ctd>Warm up your domain\u002FIP gradually\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>First 4–8 weeks\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>8\u003C\u002Ftd>\n\u003Ctd>Keep complaint rate under 0.3%\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Ongoing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>9\u003C\u002Ftd>\n\u003Ctd>Keep bounce rate under 2–3%\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Ongoing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>10\u003C\u002Ftd>\n\u003Ctd>Implement list hygiene &amp; suppression\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Ongoing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>11\u003C\u002Ftd>\n\u003Ctd>Add one-click unsubscribe (bulk senders)\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Once\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>12\u003C\u002Ftd>\n\u003Ctd>Monitor deliverability &amp; reputation\u003C\u002Ftd>\n\u003Ctd>Critical\u003C\u002Ftd>\n\u003Ctd>Ongoing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>13\u003C\u002Ftd>\n\u003Ctd>Authenticate links &amp; avoid spammy content\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Ongoing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>14\u003C\u002Ftd>\n\u003Ctd>Set up DMARC reporting and review it\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Monthly\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Featured-snippet summary: \u003Cstrong>To improve email deliverability, a SaaS startup should (1) send from a dedicated domain, (2) configure SPF, DKIM, and DMARC, (3) separate transactional from marketing email, (4) warm up sending volume, (5) keep complaint and bounce rates low, and (6) continuously monitor reputation.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch2>1. Authentication: SPF, DKIM, and DMARC\u003C\u002Fh2>\n\u003Cp>Authentication is the single highest-leverage thing on this email deliverability checklist. Since February 2024, \u003Cstrong>Gmail and Yahoo require SPF, DKIM, and DMARC\u003C\u002Fstrong> for senders, and bulk senders must pass all three or get throttled and junked (\u003Ca href=\"https:\u002F\u002Fsupport.google.com\u002Fmail\u002Fanswer\u002F81126\">Google sender guidelines\u003C\u002Fa>, \u003Ca href=\"https:\u002F\u002Fsenders.yahooinc.com\u002Fbest-practices\u002F\">Yahoo sender best practices\u003C\u002Fa>).\u003C\u002Fp>\n\u003Ch3>SPF (Sender Policy Framework)\u003C\u002Fh3>\n\u003Cp>SPF is a DNS TXT record listing which servers are allowed to send mail for your domain. The receiving server checks the envelope sender's domain against this record.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">; Example SPF record for the domain mail.yoursaas.com\nmail.yoursaas.com. IN TXT &quot;v=spf1 include:_spf.postwing.app -all&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Rules to get SPF right:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>You can have only \u003Cstrong>one\u003C\u002Fstrong> SPF record per domain. Merge multiple \u003Ccode>include:\u003C\u002Fcode> statements into one record.\u003C\u002Fli>\n\u003Cli>SPF allows a maximum of \u003Cstrong>10 DNS lookups\u003C\u002Fstrong>. Exceeding it causes a \u003Ccode>permerror\u003C\u002Fcode> and SPF failure.\u003C\u002Fli>\n\u003Cli>End with \u003Ccode>-all\u003C\u002Fcode> (hard fail) once you're confident, or \u003Ccode>~all\u003C\u002Fcode> (soft fail) while testing.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>DKIM (DomainKeys Identified Mail)\u003C\u002Fh3>\n\u003Cp>DKIM cryptographically signs each message. The receiver fetches your public key from DNS and verifies the signature, proving the message wasn't altered and really came from your domain.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">; DKIM public key published as a TXT record at &lt;selector&gt;._domainkey.yourdomain\npw1._domainkey.mail.yoursaas.com. IN TXT &quot;v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQ...&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Most email providers (including Postwing) generate the key pair and give you the exact DNS record to paste. Use a \u003Cstrong>2048-bit key\u003C\u002Fstrong> where supported, and rotate keys periodically.\u003C\u002Fp>\n\u003Ch3>DMARC (Domain-based Message Authentication, Reporting &amp; Conformance)\u003C\u002Fh3>\n\u003Cp>DMARC tells receivers what to do when SPF\u002FDKIM fail, and sends you reports. It requires \u003Cstrong>alignment\u003C\u002Fstrong> — the domain in the \u003Ccode>From:\u003C\u002Fcode> header must match the SPF\u002FDKIM domain.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">; Start in monitoring mode, then tighten\n_dmarc.yoursaas.com. IN TXT &quot;v=DMARC1; p=none; rua=mailto:dmarc@yoursaas.com; fo=1; adkim=s; aspf=s&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Roll out DMARC in stages:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Stage\u003C\u002Fth>\n\u003Cth>Policy\u003C\u002Fth>\n\u003Cth>What it does\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1. Monitor\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>p=none\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Collect reports, fix gaps, break nothing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>2. Quarantine\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>p=quarantine\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Suspicious mail goes to spam\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3. Enforce\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>p=reject\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Spoofed mail is rejected outright\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Stay at \u003Ccode>p=none\u003C\u002Fcode> for 2–4 weeks, review the aggregate reports, confirm all legitimate sources align, then move to \u003Ccode>quarantine\u003C\u002Fcode> and finally \u003Ccode>reject\u003C\u002Fcode>. Skipping straight to \u003Ccode>reject\u003C\u002Fcode> can blackhole your own legitimate mail.\u003C\u002Fp>\n\u003Ch2>2. Use a Dedicated Sending Domain\u003C\u002Fh2>\n\u003Cp>Never send transactional email from your bare root domain (\u003Ccode>yoursaas.com\u003C\u002Fcode>) using a generic mailbox. Instead, send from a \u003Cstrong>subdomain\u003C\u002Fstrong> dedicated to mail, e.g. \u003Ccode>mail.yoursaas.com\u003C\u002Fcode> or \u003Ccode>notify.yoursaas.com\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>Why subdomains matter:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Reputation isolation.\u003C\u002Fstrong> If your marketing blast tanks reputation, your password resets on a separate subdomain stay unaffected.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Cleaner DNS.\u003C\u002Fstrong> SPF\u002FDKIM\u002FDMARC records live on the subdomain without colliding with corporate email (Google Workspace, etc.).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Easier debugging.\u003C\u002Fstrong> You can see exactly which stream is misbehaving.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A common, battle-tested structure:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Subdomain\u003C\u002Fth>\n\u003Cth>Purpose\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>mail.yoursaas.com\u003C\u002Fcode> or \u003Ccode>txn.yoursaas.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Transactional (resets, receipts, verifications)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>news.yoursaas.com\u003C\u002Fcode> or \u003Ccode>mktg.yoursaas.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Marketing \u002F newsletters\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>yoursaas.com\u003C\u002Fcode> (root)\u003C\u002Ftd>\n\u003Ctd>Human-to-human business email only\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch2>3. Separate Transactional and Marketing Streams\u003C\u002Fh2>\n\u003Cp>This is one of the most overlooked items when SaaS teams try to \u003Cstrong>improve email delivery\u003C\u002Fstrong>. Transactional and marketing email have completely different engagement profiles and risk:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional\u003C\u002Fstrong> (password resets, receipts, 2FA codes) — high open rates, expected, low complaints. You want these flying through with maximum reputation.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Marketing\u003C\u002Fstrong> (newsletters, promos) — lower engagement, higher complaint risk.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Mixing them on the same domain and IP lets your promo complaint rate drag down your password-reset inbox placement. Separate the streams at the domain\u002Fsubdomain level (and ideally at the IP level once you have volume). Most modern providers let you tag and route streams separately.\u003C\u002Fp>\n\u003Ch2>4. Warm Up Your Domain and IP\u003C\u002Fh2>\n\u003Cp>A brand-new domain or IP has no reputation. Blasting 100,000 emails on day one looks exactly like a spammer and gets you throttled or blocked. \u003Cstrong>Warm-up\u003C\u002Fstrong> means ramping volume gradually so mailbox providers learn you're legitimate.\u003C\u002Fp>\n\u003Cp>A reasonable warm-up curve for a new sending domain:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Day\u003C\u002Fth>\n\u003Cth>Approx. daily volume\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1–2\u003C\u002Ftd>\n\u003Ctd>50–100\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3–5\u003C\u002Ftd>\n\u003Ctd>500\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>6–10\u003C\u002Ftd>\n\u003Ctd>2,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>11–15\u003C\u002Ftd>\n\u003Ctd>10,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>16–25\u003C\u002Ftd>\n\u003Ctd>50,000\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>26+\u003C\u002Ftd>\n\u003Ctd>Full volume\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>During warm-up:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Send to your \u003Cstrong>most engaged\u003C\u002Fstrong> recipients first (real, active users).\u003C\u002Fli>\n\u003Cli>Prioritize transactional mail — it has the best engagement signals.\u003C\u002Fli>\n\u003Cli>Watch bounce and complaint rates daily; pause the ramp if either spikes.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>If you use a shared-IP provider (Postwing's default), the IP pool is already warmed, so you mainly need to warm the \u003Cem>domain\u003C\u002Fem> reputation, which is faster. Dedicated IPs require full warm-up and only make sense above roughly 100k+ emails\u002Fmonth.\u003C\u002Fp>\n\u003Ch2>5. List Hygiene and Bounce Handling\u003C\u002Fh2>\n\u003Cp>Sending to dead addresses is one of the fastest ways to wreck reputation. Mailbox providers treat high bounce rates as a spam signal.\u003C\u002Fp>\n\u003Cp>Your hygiene checklist:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Validate email at signup\u003C\u002Fstrong> — syntax check, MX record check, and reject obvious typos\u002Fdisposable domains.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Process bounces automatically\u003C\u002Fstrong> — remove hard-bounced addresses immediately; never retry them.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Suppress complainers\u003C\u002Fstrong> — anyone who hits \"spam\" should be permanently suppressed.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Re-engage or remove inactives\u003C\u002Fstrong> — for marketing streams, prune addresses with no opens\u002Fclicks in 90–180 days.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Handle bounces and complaints with webhooks. Here's a minimal handler in Node.js:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-js\">\u002F\u002F POST endpoint that receives delivery-event webhooks\napp.post(&quot;\u002Fwebhooks\u002Femail&quot;, express.json(), async (req, res) =&gt; {\n  const event = req.body;\n\n  switch (event.type) {\n    case &quot;bounce&quot;:\n      if (event.bounce_type === &quot;hard&quot;) {\n        await db.suppressEmail(event.recipient, &quot;hard_bounce&quot;);\n      }\n      break;\n    case &quot;complaint&quot;:\n      await db.suppressEmail(event.recipient, &quot;complaint&quot;);\n      break;\n    case &quot;delivered&quot;:\n      await db.markDelivered(event.message_id);\n      break;\n  }\n\n  res.sendStatus(200);\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Before sending, always check the suppression list:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">def send_transactional(recipient, template, data):\n    if suppression.is_suppressed(recipient):\n        log.info(&quot;Skipping suppressed address: %s&quot;, recipient)\n        return\n    api.send(to=recipient, template=template, data=data)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Target thresholds: \u003Cstrong>bounce rate &lt; 2–3%\u003C\u002Fstrong>, \u003Cstrong>complaint rate &lt; 0.1%\u003C\u002Fstrong> (hard cap 0.3% per Gmail\u002FYahoo).\u003C\u002Fp>\n\u003Ch2>6. One-Click Unsubscribe and Content Hygiene\u003C\u002Fh2>\n\u003Cp>For any bulk or marketing mail, Gmail and Yahoo now require \u003Cstrong>one-click unsubscribe\u003C\u002Fstrong> (RFC 8058) via the \u003Ccode>List-Unsubscribe\u003C\u002Fcode> and \u003Ccode>List-Unsubscribe-Post\u003C\u002Fcode> headers:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>List-Unsubscribe: &lt;https:\u002F\u002Fyoursaas.com\u002Fu\u002Fabc123&gt;, &lt;mailto:unsub@yoursaas.com&gt;\nList-Unsubscribe-Post: List-Unsubscribe=One-Click\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Pure transactional mail (a password reset) doesn't legally need an unsubscribe link, but anything resembling marketing does. When in doubt, include it.\u003C\u002Fp>\n\u003Cp>Content hygiene that affects placement:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Balance text and images.\u003C\u002Fstrong> Image-only emails with little text look spammy.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Use a real, monitored \u003Ccode>From:\u003C\u002Fcode> address\u003C\u002Fstrong> — avoid \u003Ccode>noreply@\u003C\u002Fcode> when you can; reply-ability is a positive signal.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep links clean.\u003C\u002Fstrong> Don't wrap everything in shorteners or mismatched redirect domains.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Avoid spam-trigger formatting\u003C\u002Fstrong> — ALL CAPS subjects, \"FREE!!!\", excessive exclamation points.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authenticate your link domains\u003C\u002Fstrong> too (BIMI and consistent click-tracking domains help).\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>7. Monitor Deliverability and Reputation\u003C\u002Fh2>\n\u003Cp>You can't improve what you don't measure. Continuous monitoring is what separates teams that maintain inbox placement from teams that discover a problem only when support tickets pile up.\u003C\u002Fp>\n\u003Cp>Set up monitoring across these layers:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Tool \u002F signal\u003C\u002Fth>\n\u003Cth>What it tells you\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Provider dashboard (delivered\u002Fbounced\u002Fcomplained)\u003C\u002Ftd>\n\u003Ctd>Real-time stream health\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ca href=\"https:\u002F\u002Fpostmaster.google.com\u002F\">Google Postmaster Tools\u003C\u002Fa>\u003C\u002Ftd>\n\u003Ctd>Gmail domain &amp; IP reputation, spam rate\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>DMARC aggregate reports (\u003Ccode>rua\u003C\u002Fcode>)\u003C\u002Ftd>\n\u003Ctd>Who's sending as you, alignment failures\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Seed\u002Finbox-placement tests\u003C\u002Ftd>\n\u003Ctd>Actual inbox vs spam placement across providers\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Blocklist checks (Spamhaus, etc.)\u003C\u002Ftd>\n\u003Ctd>Whether your domain\u002FIP got listed\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Operational rules:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Alert when complaint rate crosses \u003Cstrong>0.2%\u003C\u002Fstrong> (before Gmail's 0.3% red line).\u003C\u002Fli>\n\u003Cli>Alert on bounce-rate spikes — usually a bad import or a broken signup flow.\u003C\u002Fli>\n\u003Cli>Review DMARC reports monthly to catch new unauthorized senders.\u003C\u002Fli>\n\u003Cli>Track inbox-placement trends, not just the daily delivery number.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Common Mistakes That Hurt Email Deliverability\u003C\u002Fh2>\n\u003Cp>Even teams that set up SPF\u002FDKIM\u002FDMARC correctly sabotage themselves with these:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Sending from the root domain or a free mailbox.\u003C\u002Fstrong> \u003Ccode>yourstartup@gmail.com\u003C\u002Fcode> as your app sender will not scale and looks untrustworthy. Use an authenticated custom domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Going straight to \u003Ccode>p=reject\u003C\u002Fcode> on DMARC.\u003C\u002Fstrong> Without a monitoring period, you blackhole legitimate mail you forgot about (your CRM, billing tool, etc.).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mixing transactional and marketing on one domain.\u003C\u002Fstrong> A bad campaign sinks your password resets.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring bounces.\u003C\u002Fstrong> Repeatedly mailing dead addresses is a textbook spammer pattern.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No warm-up.\u003C\u002Fstrong> Day-one volume spikes from a fresh domain get throttled hard.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Treating deliverability as one-time setup.\u003C\u002Fstrong> Reputation decays; an unmonitored sender drifts into spam over months.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Buying or scraping lists.\u003C\u002Fstrong> The single fastest path to spam traps and permanent reputation damage.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>One SPF record with &gt;10 DNS lookups.\u003C\u002Fstrong> Silent \u003Ccode>permerror\u003C\u002Fcode> makes SPF fail on every send.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Using \u003Ccode>noreply@\u003C\u002Fcode> everywhere.\u003C\u002Fstrong> Some engagement signals come from replies; reply-able addresses help reputation.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Choosing Infrastructure: Self-Hosted SMTP vs an Email API\u003C\u002Fh2>\n\u003Cp>Many startups try to run their own Postfix\u002FSMTP server to save money, then spend months fighting blocklists and warm-up. Here's the honest tradeoff:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Factor\u003C\u002Fth>\n\u003Cth>Self-hosted SMTP\u003C\u002Fth>\n\u003Cth>Transactional Email API\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Initial setup\u003C\u002Ftd>\n\u003Ctd>Days–weeks (DNS, TLS, PTR, warm-up)\u003C\u002Ftd>\n\u003Ctd>Minutes\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>IP\u002Fdomain reputation\u003C\u002Ftd>\n\u003Ctd>You build and defend it\u003C\u002Ftd>\n\u003Ctd>Managed, pre-warmed pools\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Deliverability tooling\u003C\u002Ftd>\n\u003Ctd>Build it yourself\u003C\u002Ftd>\n\u003Ctd>Built-in dashboards &amp; webhooks\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Maintenance\u003C\u002Ftd>\n\u003Ctd>Ongoing (patching, blocklist removal)\u003C\u002Ftd>\n\u003Ctd>Handled by provider\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Scaling\u003C\u002Ftd>\n\u003Ctd>Manual IP warm-up\u003C\u002Ftd>\n\u003Ctd>Automatic\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Cost at low volume\u003C\u002Ftd>\n\u003Ctd>Server + your time\u003C\u002Ftd>\n\u003Ctd>Pay per email\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>For most SaaS startups, a transactional email API is the correct choice until you have very high, predictable volume and a dedicated infra team. It gets you a clean, warmed, authenticated sending setup on day one — checking off most of this email deliverability checklist automatically.\u003C\u002Fp>\n\u003Ch2>FAQ\u003C\u002Fh2>\n\u003Ch3>What is an email deliverability checklist?\u003C\u002Fh3>\n\u003Cp>An email deliverability checklist is a structured list of technical and operational steps — authentication (SPF, DKIM, DMARC), dedicated sending domains, stream separation, warm-up, list hygiene, and monitoring — that maximizes the share of emails reaching the inbox instead of spam. SaaS teams use it both for initial setup and as a recurring audit.\u003C\u002Fp>\n\u003Ch3>How can a SaaS startup quickly improve email delivery?\u003C\u002Fh3>\n\u003Cp>The fastest wins to improve email delivery are: configure SPF, DKIM, and DMARC correctly; send from a dedicated subdomain; separate transactional from marketing email; remove hard-bounced and complaining addresses immediately; and start monitoring with Google Postmaster Tools. These five steps resolve the majority of inbox-placement problems.\u003C\u002Fp>\n\u003Ch3>What is a good email deliverability rate for transactional email?\u003C\u002Fh3>\n\u003Cp>Healthy transactional email should achieve \u003Cstrong>inbox placement above 95%\u003C\u002Fstrong>, a \u003Cstrong>bounce rate under 2–3%\u003C\u002Fstrong>, and a \u003Cstrong>complaint rate under 0.1%\u003C\u002Fstrong> (with 0.3% being the hard limit Gmail and Yahoo enforce). Note that a \"delivery rate\" of 99% can still hide poor \u003Cem>inbox placement\u003C\u002Fem>, so measure placement, not just delivery.\u003C\u002Fp>\n\u003Ch3>Do I need DMARC if I already have SPF and DKIM?\u003C\u002Fh3>\n\u003Cp>Yes. Since 2024, Gmail and Yahoo require DMARC for bulk senders, and it's strongly recommended for everyone. SPF and DKIM authenticate the message, but only DMARC enforces alignment with your \u003Ccode>From:\u003C\u002Fcode> domain, prevents spoofing, and gives you reporting visibility into who's sending as you.\u003C\u002Fp>\n\u003Ch3>Should transactional and marketing emails use the same domain?\u003C\u002Fh3>\n\u003Cp>No. Use separate subdomains (and ideally separate IPs at scale). Marketing email carries higher complaint risk; keeping it isolated protects the reputation of critical transactional mail like password resets and 2FA codes, so a bad campaign can't push your account emails into spam.\u003C\u002Fp>\n\u003Ch3>How long does it take to warm up a sending domain?\u003C\u002Fh3>\n\u003Cp>Domain warm-up typically takes \u003Cstrong>4–8 weeks\u003C\u002Fstrong>, ramping volume gradually from dozens of emails per day to your full volume while monitoring bounce and complaint rates. Using a provider with pre-warmed shared IP pools shortens this significantly, since you only need to establish domain reputation, not IP reputation.\u003C\u002Fp>\n\u003Ch3>Why do my password reset emails go to spam even with authentication?\u003C\u002Fh3>\n\u003Cp>Authentication is necessary but not sufficient. Password resets can still land in spam due to a new\u002Fcold sending domain, low overall domain reputation, content that triggers filters, sharing a domain with poor-reputation marketing mail, or sending from a blocklisted IP. Fix it by isolating transactional mail on a warmed, reputable, dedicated domain and monitoring placement.\u003C\u002Fp>\n\u003Ch3>Which tools should I use to monitor deliverability?\u003C\u002Fh3>\n\u003Cp>Use Google Postmaster Tools for Gmail reputation and spam rate, your email provider's dashboard for real-time delivered\u002Fbounced\u002Fcomplained events, DMARC aggregate reports for authentication visibility, blocklist checkers like Spamhaus, and periodic inbox-placement (seed) tests to confirm actual inbox vs spam placement across providers.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Email deliverability isn't luck — it's a checklist. For SaaS startups, the path to reliable inbox placement is consistent: authenticate with SPF, DKIM, and DMARC; send from a dedicated, warmed domain; separate transactional from marketing streams; keep bounce and complaint rates low through disciplined list hygiene; and monitor reputation continuously. Work through this email deliverability checklist once to get set up, then revisit it quarterly, because reputation decays the moment you stop paying attention.\u003C\u002Fp>\n\u003Cp>The teams that win at deliverability treat it as ongoing infrastructure, not a one-time DNS chore. Every item above either prevents a reputation problem or catches one early — and for a SaaS product, that's the difference between users who activate and users who never get the verification email.\u003C\u002Fp>\n\u003Ch2>Send Transactional Email That Actually Lands — with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers and SaaS companies that want inbox placement without the deliverability grind. You get pre-warmed, reputable sending infrastructure, guided SPF\u002FDKIM\u002FDMARC setup, separate transactional and marketing streams, automatic bounce and complaint suppression, and real-time delivery webhooks — most of this email deliverability checklist handled out of the box.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, founders anywhere in the world can pay for email infrastructure without a Stripe account, currency friction, or chargeback risk — pay-as-you-go, in stablecoin.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Start sending deliverable transactional email in minutes — \u003Ca href=\"https:\u002F\u002Fpostwing.app\">create your Postwing account\u003C\u002Fa> and get your password resets, receipts, and verification emails into the inbox.\u003C\u002Fstrong>\u003C\u002Fp>","ba241820-8480-4b18-94ba-ce9b925c86a3","2026-06-24T18:28:18.022056+03:00","2026-07-15T23:41:21.960392+03:00","Email Deliverability Checklist for SaaS Startups","A practical email deliverability checklist for SaaS startups: SPF, DKIM, DMARC, domain setup, monitoring, and reputation tips to improve email delivery fast.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_ba241820-8480-4b18-94ba-ce9b925c86a3\u002FEmail_Deliverability_Checklist_for_SaaS_klxY56z.png","2026-07-08T09:00:00+03:00",[175,176],"email deliverability checklist","improve email delivery",{"id":178,"body":179,"uuid":180,"created_at":181,"updated_at":182,"brand":13,"header":183,"short_body":184,"image":185,"published":17,"published_at":186,"tags":187},10,"\u003Cp>Choosing the \u003Cstrong>best transactional email service\u003C\u002Fstrong> in 2026 is harder than it looks. Every provider claims \"99% deliverability,\" every pricing page hides the real cost behind dedicated IP add-ons, and every API promises to be \"developer-first.\" But when password resets land in spam, or your monthly invoice triples because you crossed an arbitrary volume tier, those marketing claims stop mattering.\u003C\u002Fp>\n\u003Cp>This guide is for the people who actually own email infrastructure: SaaS founders, software engineers, startup CTOs, and technical product managers. We compare the leading transactional email platforms on the things that decide outcomes — deliverability, API ergonomics, pricing transparency, compliance, and payment options — and we name concrete \u003Cstrong>Resend alternatives\u003C\u002Fstrong> and \u003Cstrong>Postmark alternatives\u003C\u002Fstrong> so you can match a provider to your stack instead of a billboard slogan.\u003C\u002Fp>\n\u003Cp>We include Postwing in this comparison because it solves two problems most lists ignore: a genuinely developer-first API and \u003Cstrong>USDC (crypto) payments on Base\u003C\u002Fstrong> for teams that can't or won't use a corporate credit card. We'll also be honest about where the incumbents are still the safer pick.\u003C\u002Fp>\n\u003Ch2>What Is a Transactional Email and Why the Provider Matters\u003C\u002Fh2>\n\u003Cp>A transactional email is a one-to-one message triggered by a user action: password resets, email verification, receipts, order confirmations, shipping notifications, magic links, and security alerts. Unlike marketing campaigns, these emails are expected, time-sensitive, and tied directly to revenue or account access.\u003C\u002Fp>\n\u003Cp>That changes the priorities. For marketing email, open rates dominate. For transactional email, the metrics that matter are:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Inbox placement\u003C\u002Fstrong> — does the password reset reach the inbox, not spam or Promotions?\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Latency\u003C\u002Fstrong> — how fast does the message leave your API and arrive?\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Reliability\u003C\u002Fstrong> — what's the provider's uptime and queue behavior under load?\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Reputation isolation\u003C\u002Fstrong> — are your transactional sends shielded from other customers' spam?\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A bad provider doesn't just hurt a metric. If verification emails don't arrive, users can't sign up. If receipts bounce, support tickets pile up. Picking the \u003Cstrong>best transactional email service\u003C\u002Fstrong> for your situation is an infrastructure decision, not a marketing one.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Featured-snippet answer:\u003C\u002Fstrong> The best transactional email service is the one that delivers password resets, receipts, and verification emails reliably to the inbox, exposes a clean REST API and SDKs, prices predictably as you scale, and gives you DKIM\u002FSPF\u002FDMARC and webhook event data without surprise add-ons.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>How to Evaluate the Best Transactional Email Service\u003C\u002Fh2>\n\u003Cp>Before looking at logos, decide what you're optimizing for. Run every candidate through these seven criteria.\u003C\u002Fp>\n\u003Ch3>1. Deliverability and Reputation\u003C\u002Fh3>\n\u003Cp>Deliverability is the whole game. Look for providers that enforce \u003Cstrong>DKIM, SPF, and DMARC\u003C\u002Fstrong> during onboarding, offer dedicated IPs for high-volume senders, and separate transactional from bulk marketing traffic so a marketing blast can't poison your reset emails. Ask whether shared-IP pools are actively monitored and whether the provider de-lists abusive senders.\u003C\u002Fp>\n\u003Ch3>2. API and SDK Quality\u003C\u002Fh3>\n\u003Cp>You'll live in this API. Evaluate REST clarity, official SDKs (Node, Python, Go, PHP, Ruby), idempotency support, batch sending, and how templates are handled (server-side templating vs. you rendering HTML). A clean API saves weeks over the life of a product.\u003C\u002Fp>\n\u003Ch3>3. Pricing Transparency\u003C\u002Fh3>\n\u003Cp>The headline price is rarely the real price. Watch for dedicated IP fees, overage rates, \"validation\" or \"dedicated support\" upsells, and the volume tier where pricing jumps. Predictable per-thousand pricing beats a low entry price with cliffs.\u003C\u002Fp>\n\u003Ch3>4. Analytics and Webhooks\u003C\u002Fh3>\n\u003Cp>You need delivery, bounce, open, click, spam-complaint, and deferral events — ideally as real-time webhooks you can feed into your own dashboards. Without event data you're flying blind on deliverability.\u003C\u002Fp>\n\u003Ch3>5. Compliance and Data Residency\u003C\u002Fh3>\n\u003Cp>GDPR, SOC 2, and (for some industries) HIPAA matter. Check where data is stored, retention defaults for email content, and whether a DPA is available.\u003C\u002Fp>\n\u003Ch3>6. Deliverability Tooling\u003C\u002Fh3>\n\u003Cp>Inbox-placement testing, suppression-list management, and a sending-domain reputation dashboard separate serious infrastructure providers from thin API wrappers.\u003C\u002Fp>\n\u003Ch3>7. Payment Flexibility\u003C\u002Fh3>\n\u003Cp>Underrated until it blocks you. Many global teams, indie developers, and crypto-native companies struggle with credit-card-only billing. Providers that accept alternatives — including \u003Cstrong>USDC stablecoin payments\u003C\u002Fstrong> — remove a real onboarding barrier.\u003C\u002Fp>\n\u003Ch2>The Best Transactional Email Providers in 2026 (Comparison)\u003C\u002Fh2>\n\u003Cp>Here's a side-by-side overview of the leading options. Treat exact prices as directional — always confirm on each provider's current pricing page, since tiers change.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Provider\u003C\u002Fth>\n\u003Cth>Best for\u003C\u002Fth>\n\u003Cth>Free tier\u003C\u002Fth>\n\u003Cth>API \u002F DX\u003C\u002Fth>\n\u003Cth>Payment options\u003C\u002Fth>\n\u003Cth>Standout strength\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Postwing\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Developer-first teams, crypto-native &amp; global SaaS\u003C\u002Ftd>\n\u003Ctd>Yes (starter volume)\u003C\u002Ftd>\n\u003Ctd>Modern REST + SDKs\u003C\u002Ftd>\n\u003Ctd>Card \u003Cstrong>+ USDC on Base\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Pay-as-you-go, USDC billing, no card required\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Postmark\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Pure transactional, deliverability purists\u003C\u002Ftd>\n\u003Ctd>Limited trial\u003C\u002Ftd>\n\u003Ctd>Excellent, mature\u003C\u002Ftd>\n\u003Ctd>Card\u003C\u002Ftd>\n\u003Ctd>Best-in-class transactional inbox placement\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Resend\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Modern stacks, React Email\u003C\u002Ftd>\n\u003Ctd>Yes (3k\u002Fmo)\u003C\u002Ftd>\n\u003Ctd>Excellent, modern\u003C\u002Ftd>\n\u003Ctd>Card\u003C\u002Ftd>\n\u003Ctd>Great DX, React Email integration\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Amazon SES\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>High volume, ops-heavy teams\u003C\u002Ftd>\n\u003Ctd>Generous (in-AWS)\u003C\u002Ftd>\n\u003Ctd>Low-level, raw\u003C\u002Ftd>\n\u003Ctd>AWS billing\u003C\u002Ftd>\n\u003Ctd>Lowest per-email cost at scale\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>SendGrid\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Mixed marketing + transactional\u003C\u002Ftd>\n\u003Ctd>Yes (limited)\u003C\u002Ftd>\n\u003Ctd>Mature, broad\u003C\u002Ftd>\n\u003Ctd>Card\u003C\u002Ftd>\n\u003Ctd>All-in-one platform, large ecosystem\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Mailgun\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Developers wanting email-validation tooling\u003C\u002Ftd>\n\u003Ctd>Trial\u003C\u002Ftd>\n\u003Ctd>Strong API\u003C\u002Ftd>\n\u003Ctd>Card\u003C\u002Ftd>\n\u003Ctd>Routing, validation, EU region\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Loops \u002F Customer.io\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Lifecycle + transactional combined\u003C\u002Ftd>\n\u003Ctd>Varies\u003C\u002Ftd>\n\u003Ctd>Good\u003C\u002Ftd>\n\u003Ctd>Card\u003C\u002Ftd>\n\u003Ctd>Marketing + transactional in one tool\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>Postmark\u003C\u002Fh3>\n\u003Cp>Postmark built its reputation by refusing to be a marketing tool. It separates transactional and broadcast streams, and its shared-IP pools are aggressively curated, which is why it's repeatedly cited for strong transactional inbox placement. The API is clean and well-documented, and its message-event detail (full SMTP activity, opens, clicks, bounces) is among the best for debugging.\u003C\u002Fp>\n\u003Cp>Where it falls short for some teams: pricing scales by volume and can get expensive at higher tiers, and it's credit-card-only. If you want one tool for both newsletters and receipts, Postmark intentionally isn't it.\u003C\u002Fp>\n\u003Ch3>Resend\u003C\u002Fh3>\n\u003Cp>Resend is the modern developer darling, especially for teams using React. Its \u003Cstrong>React Email\u003C\u002Fstrong> library lets you build templates as components, the dashboard is clean, and the API is minimal and pleasant. The free tier (around 3,000 emails\u002Fmonth) makes it easy to start.\u003C\u002Fp>\n\u003Cp>It's a strong default for greenfield projects — but as you scale, you may want more granular deliverability tooling, dedicated-IP control, or billing flexibility. That's exactly where teams start searching for \u003Cstrong>Resend alternatives\u003C\u002Fstrong>.\u003C\u002Fp>\n\u003Ch3>Amazon SES\u003C\u002Fh3>\n\u003Cp>SES is the cost leader. At roughly $0.10 per 1,000 emails, nothing beats it on raw price, and it scales effortlessly inside AWS. The trade-off is everything else: SES is a low-level service. You build your own templating, suppression handling, analytics, and retry logic, and reputation management is your job. For ops-heavy teams already deep in AWS, it's unbeatable. For a three-person startup, it's a part-time job.\u003C\u002Fp>\n\u003Ch3>SendGrid\u003C\u002Fh3>\n\u003Cp>SendGrid (Twilio) is the broad all-in-one. It handles transactional and marketing, has a huge integration ecosystem, and offers a free tier. The downsides developers cite are a heavier UI, deliverability that can wobble on shared IPs without careful warm-up, and support that varies by plan. It's a safe institutional choice more than a developer-delight one.\u003C\u002Fp>\n\u003Ch3>Mailgun\u003C\u002Fh3>\n\u003Cp>Mailgun is a solid API-first option with strong inbound routing, an EU region for data residency, and built-in email validation. It sits comfortably as both a \u003Cstrong>Postmark alternative\u003C\u002Fstrong> and a \u003Cstrong>Resend alternative\u003C\u002Fstrong> for teams that want validation tooling and flexible routing without leaving a familiar developer experience.\u003C\u002Fp>\n\u003Ch3>Postwing\u003C\u002Fh3>\n\u003Cp>Postwing is built around two ideas the incumbents under-serve. First, a genuinely \u003Cstrong>developer-first API\u003C\u002Fstrong> with pay-as-you-go pricing and no volume cliffs, so a side project and a scaling SaaS use the same clean interface. Second, \u003Cstrong>USDC payments on Base\u003C\u002Fstrong> — you fund your balance with on-chain USDC, the backend watches the chain and credits you after confirmation, and you never hand over a corporate card.\u003C\u002Fp>\n\u003Cp>That matters more than it sounds. International founders without easy access to US\u002FEU card rails, crypto-native companies, and privacy-conscious teams routinely get stuck at the billing step with other providers. Postwing removes that wall while still doing the table-stakes work: DKIM\u002FSPF\u002FDMARC setup, delivery and bounce webhooks, and email-log visibility. If you've been hunting for \u003Cstrong>Resend alternatives\u003C\u002Fstrong> or \u003Cstrong>Postmark alternatives\u003C\u002Fstrong> specifically because of payment friction or pricing predictability, it's a natural fit.\u003C\u002Fp>\n\u003Ch2>Practical Example: Sending a Password Reset\u003C\u002Fh2>\n\u003Cp>The mechanics of sending are similar across providers — a POST to a send endpoint with from, to, subject, and body. Here's a representative transactional send using a Postwing-style REST API in Python:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import requests\n\nAPI_KEY = &quot;pw_live_xxxxxxxxxxxx&quot;\n\nresp = requests.post(\n    &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Femails&quot;,\n    headers={\n        &quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;,\n        &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n    },\n    json={\n        &quot;from&quot;: &quot;noreply@yourapp.com&quot;,\n        &quot;to&quot;: &quot;user@example.com&quot;,\n        &quot;subject&quot;: &quot;Reset your password&quot;,\n        &quot;html&quot;: (\n            &quot;&lt;p&gt;Click the link below to reset your password:&lt;\u002Fp&gt;&quot;\n            &quot;&lt;p&gt;&lt;a href='https:\u002F\u002Fyourapp.com\u002Freset?token=abc123'&gt;&quot;\n            &quot;Reset password&lt;\u002Fa&gt;&lt;\u002Fp&gt;&quot;\n        ),\n        # Idempotency protects against duplicate sends on retry\n        &quot;idempotency_key&quot;: &quot;reset-user-9182-1719158400&quot;,\n    },\n    timeout=10,\n)\n\nresp.raise_for_status()\nprint(&quot;Queued:&quot;, resp.json()[&quot;id&quot;])\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The same call in Node.js:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">const res = await fetch(&quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Femails&quot;, {\n  method: &quot;POST&quot;,\n  headers: {\n    Authorization: `Bearer ${process.env.POSTWING_API_KEY}`,\n    &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n  },\n  body: JSON.stringify({\n    from: &quot;noreply@yourapp.com&quot;,\n    to: &quot;user@example.com&quot;,\n    subject: &quot;Reset your password&quot;,\n    html: `&lt;p&gt;&lt;a href=&quot;https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;&gt;Reset password&lt;\u002Fa&gt;&lt;\u002Fp&gt;`,\n    idempotency_key: &quot;reset-user-9182-1719158400&quot;,\n  }),\n});\n\nif (!res.ok) throw new Error(`Send failed: ${res.status}`);\nconst { id } = await res.json();\nconsole.log(&quot;Queued:&quot;, id);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Two details separate production-grade code from a demo:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Idempotency keys\u003C\u002Fstrong> prevent duplicate emails when your app retries a failed request. A user receiving five \"reset your password\" emails erodes trust fast.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Timeouts and error handling\u003C\u002Fstrong> keep a slow email API from blocking your request path. Better still, send asynchronously via a queue so a provider hiccup never delays a user's login.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Practical Example: Consuming Delivery Webhooks\u003C\u002Fh2>\n\u003Cp>Sending is half the job; knowing what happened is the other half. Subscribe to webhook events and persist them so you can answer \"did this user actually receive their receipt?\"\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">from flask import Flask, request, abort\nimport hmac, hashlib\n\napp = Flask(__name__)\nWEBHOOK_SECRET = b&quot;whsec_xxxxxxxx&quot;\n\n@app.post(&quot;\u002Fwebhooks\u002Femail&quot;)\ndef email_webhook():\n    signature = request.headers.get(&quot;X-Webhook-Signature&quot;, &quot;&quot;)\n    expected = hmac.new(WEBHOOK_SECRET, request.data, hashlib.sha256).hexdigest()\n    if not hmac.compare_digest(signature, expected):\n        abort(401)\n\n    event = request.get_json()\n    # event[&quot;type&quot;] in: delivered, bounced, complained, deferred, opened\n    if event[&quot;type&quot;] == &quot;bounced&quot;:\n        suppress_address(event[&quot;data&quot;][&quot;to&quot;])  # stop sending to dead addresses\n\n    return &quot;&quot;, 204\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Always verify the webhook signature — an unauthenticated webhook endpoint is an open door for forged \"bounce\" events that could wrongly suppress real users.\u003C\u002Fp>\n\u003Ch2>Setting Up Authentication: DKIM, SPF, and DMARC\u003C\u002Fh2>\n\u003Cp>No provider can save you from a misconfigured sending domain. Whatever you choose, set these three records before you send a single production email:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF\u003C\u002Fstrong> — a TXT record listing the servers allowed to send for your domain. Example: \u003Ccode>v=spf1 include:postwing.app ~all\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM\u003C\u002Fstrong> — a cryptographic signature (a TXT\u002FCNAME record your provider gives you) that proves the message wasn't altered in transit.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC\u003C\u002Fstrong> — a policy record that tells receivers what to do when SPF\u002FDKIM fail, and where to send reports. Start with \u003Ccode>p=none\u003C\u002Fcode> to monitor, then tighten to \u003Ccode>p=quarantine\u003C\u002Fcode> and \u003Ccode>p=reject\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A correct DMARC alignment is increasingly mandatory: Google and Yahoo now require authentication for bulk senders, and consumer mailboxes increasingly penalize unauthenticated mail. The best transactional email service makes this setup a guided, copy-paste step rather than a research project.\u003C\u002Fp>\n\u003Ch2>Common Mistakes When Choosing a Provider\u003C\u002Fh2>\n\u003Cp>Even experienced teams trip on the same issues. Avoid these.\u003C\u002Fp>\n\u003Ch3>Mixing Marketing and Transactional on One IP\u003C\u002Fh3>\n\u003Cp>Sending a 50,000-recipient newsletter and your password resets from the same IP means one spam complaint spike can tank reset deliverability. Use separate streams or separate providers for transactional vs. broadcast.\u003C\u002Fp>\n\u003Ch3>Optimizing for the Sticker Price\u003C\u002Fh3>\n\u003Cp>SES at $0.10\u002F1,000 looks irresistible until you've spent two engineering weeks building templating, suppression, and analytics you'd have gotten for free elsewhere. Total cost of ownership includes engineering time, not just the invoice.\u003C\u002Fp>\n\u003Ch3>Ignoring the Pricing Cliff\u003C\u002Fh3>\n\u003Cp>Many providers are cheap at the free tier and expensive the moment you cross it. Model your cost at 10x and 100x your current volume before committing. Pay-as-you-go pricing avoids nasty step functions.\u003C\u002Fp>\n\u003Ch3>Skipping Idempotency and Async Sending\u003C\u002Fh3>\n\u003Cp>Calling the email API synchronously in your request path means a provider slowdown becomes a user-facing slowdown. Queue sends, use idempotency keys, and retry with backoff.\u003C\u002Fp>\n\u003Ch3>Treating Deliverability as Set-and-Forget\u003C\u002Fh3>\n\u003Cp>Domain reputation drifts. Monitor bounce and complaint rates, keep suppression lists clean, and watch your DMARC reports. A provider gives you the rails; you still drive.\u003C\u002Fp>\n\u003Ch3>Getting Blocked at Billing\u003C\u002Fh3>\n\u003Cp>A surprisingly common failure: the provider is perfect technically but won't accept your payment method, or requires a US business entity. If your team is international or crypto-native, confirm payment options — including alternatives like \u003Cstrong>USDC\u003C\u002Fstrong> — before you integrate.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>What is the best transactional email service in 2026?\u003C\u002Fh3>\n\u003Cp>There's no single winner — it depends on your stack and constraints. Postmark leads on pure transactional deliverability, Resend on modern developer experience, Amazon SES on raw cost at scale, and Postwing on pay-as-you-go pricing plus USDC payments for global and crypto-native teams. Match the provider to your priorities (inbox placement, DX, cost, or billing flexibility) rather than chasing a universal \"best.\"\u003C\u002Fp>\n\u003Ch3>What are the best Resend alternatives?\u003C\u002Fh3>\n\u003Cp>The strongest \u003Cstrong>Resend alternatives\u003C\u002Fstrong> are Postmark (if you want maximum transactional deliverability), Postwing (if you want similar developer ergonomics plus pay-as-you-go and USDC billing), Mailgun (if you need validation and routing tooling), and Amazon SES (if you want the lowest possible per-email cost and can build the surrounding tooling yourself).\u003C\u002Fp>\n\u003Ch3>What are the best Postmark alternatives?\u003C\u002Fh3>\n\u003Cp>Good \u003Cstrong>Postmark alternatives\u003C\u002Fstrong> include Resend and Postwing for a more modern API and pricing experience, SendGrid if you need marketing and transactional in one platform, and Amazon SES for high-volume cost optimization. Choose based on whether you value DX, all-in-one features, or price.\u003C\u002Fp>\n\u003Ch3>How much does transactional email cost?\u003C\u002Fh3>\n\u003Cp>It ranges widely. Amazon SES is roughly $0.10 per 1,000 emails but requires significant engineering. Managed providers typically run from a few dollars per 10,000–50,000 emails up to volume-based plans in the hundreds of dollars monthly. Watch for dedicated-IP fees ($50–$100+\u002Fmonth) and overage rates. Pay-as-you-go models, like Postwing's, charge for what you send without forcing you into a tier.\u003C\u002Fp>\n\u003Ch3>Can I pay for email sending with crypto or USDC?\u003C\u002Fh3>\n\u003Cp>Most major providers are credit-card only. Postwing is built to accept \u003Cstrong>USDC stablecoin payments on Base\u003C\u002Fstrong> — you deposit on-chain USDC and your balance is credited after confirmation — which removes the card requirement entirely. This is valuable for international founders, crypto-native companies, and teams that prefer not to put email infrastructure on a corporate card.\u003C\u002Fp>\n\u003Ch3>Should I use one provider for both marketing and transactional email?\u003C\u002Fh3>\n\u003Cp>Generally, no. Keep them separate (or on separate streams) so marketing volume and complaint rates can't damage the deliverability of critical transactional mail like password resets and receipts. Some all-in-one tools (SendGrid, Customer.io) support both, but they isolate the streams internally — make sure yours does.\u003C\u002Fp>\n\u003Ch3>How do I improve transactional email deliverability?\u003C\u002Fh3>\n\u003Cp>Authenticate with DKIM, SPF, and DMARC; warm up new sending domains gradually; keep bounce and spam-complaint rates low by honoring suppression lists; send transactional mail from a dedicated subdomain; and monitor delivery webhooks so you catch problems before users do.\u003C\u002Fp>\n\u003Ch3>Do I need a dedicated IP for transactional email?\u003C\u002Fh3>\n\u003Cp>Only at scale. Below roughly 100,000 emails\u002Fmonth, a well-managed shared IP pool usually delivers better than a cold dedicated IP you can't warm up properly. Above that, a dedicated IP gives you full control of your reputation. Choose a provider that lets you start shared and upgrade.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>The \u003Cstrong>best transactional email service\u003C\u002Fstrong> in 2026 isn't a single product — it's the one that fits how your team builds, ships, and pays. Postmark and Resend remain excellent, well-earned defaults; Amazon SES is unbeatable on cost for ops-heavy teams; SendGrid and Mailgun cover all-in-one and tooling-heavy needs respectively.\u003C\u002Fp>\n\u003Cp>But two requirements keep pushing developers to look for \u003Cstrong>Resend alternatives\u003C\u002Fstrong> and \u003Cstrong>Postmark alternatives\u003C\u002Fstrong>: predictable pay-as-you-go pricing without volume cliffs, and payment flexibility that doesn't assume a US\u002FEU corporate credit card. Decide your non-negotiables first — deliverability, DX, cost, compliance, or billing — then pick the provider that nails them, and get DKIM\u002FSPF\u002FDMARC right regardless of who you choose.\u003C\u002Fp>\n\u003Ch2>Try Postwing\u003C\u002Fh2>\n\u003Cp>If you want a developer-first transactional email API with pay-as-you-go pricing and \u003Cstrong>USDC payments on Base\u003C\u002Fstrong> — no corporate card, no surprise tiers — give \u003Cstrong>Postwing\u003C\u002Fstrong> a try. Set up your sending domain with guided DKIM\u002FSPF\u002FDMARC, send your first password reset in minutes, and fund your balance with on-chain USDC whenever you're ready to scale.\u003C\u002Fp>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing →\u003C\u002Fa>\u003C\u002Fp>","0cfda9be-c5ef-4398-9d79-b9312e40ebd2","2026-06-24T18:28:18.016515+03:00","2026-07-15T23:41:07.493580+03:00","Best Transactional Email Providers in 2026","Compare the best transactional email service options in 2026. Deliverability, pricing, APIs, and Resend & Postmark alternatives for developers and SaaS.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_0cfda9be-c5ef-4398-9d79-b9312e40ebd2\u002FBest_Transactional_Email_Providers_in_2026.png","2026-07-06T09:00:00+03:00",[188,189,190],"best transactional email service","resend alternatives","postmark alternatives",{"id":192,"body":193,"uuid":194,"created_at":195,"updated_at":196,"brand":13,"header":197,"short_body":198,"image":199,"published":17,"published_at":200,"tags":201},9,"\u003Cp>Password reset emails are the most time-sensitive messages your application sends, and they are also the ones most likely to land in spam. A user who can't find their reset link within a minute or two doesn't file a support ticket — they churn. Poor \u003Cstrong>email deliverability\u003C\u002Fstrong> on these critical transactional messages quietly costs SaaS companies sign-ups, retention, and revenue, and most engineering teams never see it happen because the email simply vanishes into a junk folder.\u003C\u002Fp>\n\u003Cp>If your \u003Cstrong>password reset email spam\u003C\u002Fstrong> rate is high, it is almost never a coincidence or a one-off filtering glitch. It is the predictable result of authentication gaps, reputation problems, content patterns, and infrastructure choices that mailbox providers like Gmail, Outlook, and Apple Mail evaluate on every single send. The good news: every one of these factors is measurable and fixable.\u003C\u002Fp>\n\u003Cp>This guide breaks down exactly why reset emails get filtered, how mailbox providers decide what is spam, and the concrete steps developers and SaaS teams can take to keep authentication emails in the inbox.\u003C\u002Fp>\n\u003Ch2>What \"Email Deliverability\" Actually Means\u003C\u002Fh2>\n\u003Cp>Email deliverability is the measure of whether your messages reach the recipient's inbox rather than the spam folder, a quarantine, or a silent drop. It is distinct from \"delivery\": a message can be \u003Cem>delivered\u003C\u002Fem> (accepted by the receiving server) but still be placed in spam, which counts against deliverability even though the SMTP transaction succeeded.\u003C\u002Fp>\n\u003Cp>For transactional email — password resets, email verifications, magic links, receipts — deliverability is the entire product. Unlike marketing email, where a 2% open rate dip is a nuisance, a reset email that lands in spam blocks a user from accessing their account \u003Cem>right now\u003C\u002Fem>.\u003C\u002Fp>\n\u003Cp>Three layers determine whether a reset email reaches the inbox:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Layer\u003C\u002Fth>\n\u003Cth>What it controls\u003C\u002Fth>\n\u003Cth>Who evaluates it\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Authentication\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Proof the message is really from your domain\u003C\u002Ftd>\n\u003Ctd>SPF, DKIM, DMARC checks at the receiving server\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Reputation\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>History of your sending IP and domain\u003C\u002Ftd>\n\u003Ctd>Mailbox providers, blocklists\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Content &amp; engagement\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>The message body, links, and how users react\u003C\u002Ftd>\n\u003Ctd>Spam filters, machine-learning models\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>A failure at any layer can route your reset email to spam. Most teams obsess over content while ignoring authentication and reputation — which is backwards, because authentication failures are the single most common cause of password reset email spam.\u003C\u002Fp>\n\u003Ch2>Why Password Reset Emails Are Especially Vulnerable\u003C\u002Fh2>\n\u003Cp>Reset emails have characteristics that spam filters are trained to distrust:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Urgent, action-driven language\u003C\u002Fstrong> — \"Reset your password now,\" \"This link expires in 15 minutes.\" Urgency and deadlines are classic phishing signals.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A single prominent link or button\u003C\u002Fstrong> — Phishing emails are built around one call-to-action link. So are reset emails.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sent to users who may not be expecting them\u003C\u002Fstrong> — A reset triggered days after sign-up arrives with no recent engagement to vouch for it.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Often sent from a separate IP or subdomain\u003C\u002Fstrong> — Many teams route transactional mail through a different path than their main domain, fragmenting reputation.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Spiky, irregular volume\u003C\u002Fstrong> — Reset volume jumps during a breach scare or a login outage, and sudden spikes look like compromise to filters.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>In short, a legitimate password reset email and a credential-phishing attempt look structurally similar. That is exactly why your authentication and reputation signals have to be airtight — they are what tell the mailbox provider \"this urgent link email is genuinely from the domain it claims.\"\u003C\u002Fp>\n\u003Ch2>Reason 1: Broken or Missing Email Authentication\u003C\u002Fh2>\n\u003Cp>Authentication is the foundation of email deliverability, and it is where most password reset spam problems originate. Modern mailbox providers — especially Gmail and Yahoo, following their 2024 bulk-sender requirements — increasingly reject or junk mail that fails authentication outright.\u003C\u002Fp>\n\u003Cp>There are three records you must get right.\u003C\u002Fp>\n\u003Ch3>SPF (Sender Policy Framework)\u003C\u002Fh3>\n\u003Cp>SPF is a DNS TXT record that lists which servers are authorized to send mail for your domain. When a receiving server gets your reset email, it checks whether the sending IP is in your SPF record.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">v=spf1 include:_spf.postwing.app -all\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Common SPF failures that send reset emails to spam:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Too many DNS lookups.\u003C\u002Fstrong> SPF allows a maximum of 10 DNS lookups. Chaining many \u003Ccode>include:\u003C\u002Fcode> mechanisms (every vendor you've ever added) blows past the limit and causes a \u003Ccode>permerror\u003C\u002Fcode>, which filters treat as a failure.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\u003Ccode>~all\u003C\u002Fcode> instead of \u003Ccode>-all\u003C\u002Fcode>.\u003C\u002Fstrong> A soft-fail (\u003Ccode>~all\u003C\u002Fcode>) tells receivers \"probably not authorized, but accept anyway.\" A hard-fail (\u003Ccode>-all\u003C\u002Fcode>) is a stronger, more trusted signal.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending from a new provider without updating SPF.\u003C\u002Fstrong> Switch ESPs and forget to update the record, and every email fails SPF instantly.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>DKIM (DomainKeys Identified Mail)\u003C\u002Fh3>\n\u003Cp>DKIM adds a cryptographic signature to each message using a private key; the receiver verifies it against a public key published in your DNS. DKIM proves the message wasn't altered in transit and genuinely originated from your domain.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">selector._domainkey.yourdomain.com  TXT  &quot;v=DKIM1; k=rsa; p=MIGfMA0GCSq...&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>DKIM is more important than SPF because it survives forwarding. If a user forwards your reset email or it passes through a mailing list, SPF often breaks but a valid DKIM signature still authenticates. Reset emails with no DKIM signature are heavily penalized.\u003C\u002Fp>\n\u003Ch3>DMARC (Domain-based Message Authentication, Reporting &amp; Conformance)\u003C\u002Fh3>\n\u003Cp>DMARC ties SPF and DKIM together and tells receivers what to do when authentication fails. It requires \u003Cem>alignment\u003C\u002Fem> — the domain in the \u003Ccode>From:\u003C\u002Fcode> header must match the authenticated domain.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">_dmarc.yourdomain.com  TXT  &quot;v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com; adkim=s; aspf=s&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>DMARC policy\u003C\u002Fth>\n\u003Cth>Effect\u003C\u002Fth>\n\u003Cth>Recommendation\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>p=none\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Monitor only, no enforcement\u003C\u002Ftd>\n\u003Ctd>Start here to gather reports\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>p=quarantine\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Failing mail goes to spam\u003C\u002Ftd>\n\u003Ctd>Intermediate step\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>p=reject\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Failing mail is rejected outright\u003C\u002Ftd>\n\u003Ctd>Target for transactional domains\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>A published DMARC record with at least \u003Ccode>p=quarantine\u003C\u002Fcode> is now effectively mandatory for bulk senders to Gmail and Yahoo. Without it, your reset emails are increasingly likely to be junked regardless of content.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Featured-snippet answer:\u003C\u002Fstrong> Password reset emails go to spam most often because of failed email authentication — missing or misconfigured SPF, DKIM, and DMARC records. Fixing these three DNS records resolves the majority of deliverability problems for transactional email.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>Reason 2: Poor Sender Reputation\u003C\u002Fh2>\n\u003Cp>Even perfectly authenticated email lands in spam if the sending domain or IP has a poor reputation. Mailbox providers maintain a reputation score for both, built from your sending history.\u003C\u002Fp>\n\u003Ch3>Shared IP contamination\u003C\u002Fh3>\n\u003Cp>If you send through a shared IP pool — common on entry-level email plans — your deliverability is hostage to every other sender on that IP. One spammer on the pool can drag your reputation down even when your own practices are flawless. For volume senders, a dedicated IP with proper warm-up isolates your reputation.\u003C\u002Fp>\n\u003Ch3>Domain reputation and subdomains\u003C\u002Fh3>\n\u003Cp>Domain reputation now matters more than IP reputation at most providers. A best practice is to separate your sending streams by subdomain:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>mail.yourdomain.com\u003C\u002Fcode> for transactional (resets, receipts)\u003C\u002Fli>\n\u003Cli>\u003Ccode>news.yourdomain.com\u003C\u002Fcode> for marketing\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>This way, an aggressive marketing campaign that triggers spam complaints can't poison the reputation of the subdomain carrying your password reset emails. Keep your transactional subdomain pristine.\u003C\u002Fp>\n\u003Ch3>Blocklists\u003C\u002Fh3>\n\u003Cp>Your IP or domain can land on a blocklist (Spamhaus, Barracuda, SpamCop) after spam complaints, hitting spam traps, or a compromised account blasting mail. Listed senders see deliverability collapse. Monitor your status and have a remediation\u002Fdelisting process ready.\u003C\u002Fp>\n\u003Ch2>Reason 3: Content and Formatting Triggers\u003C\u002Fh2>\n\u003Cp>Reset email content can trip spam filters even when authentication and reputation are solid. Spam-detection engines like SpamAssassin score messages against hundreds of rules.\u003C\u002Fp>\n\u003Cp>Common content triggers in password reset emails:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Image-only or image-heavy emails.\u003C\u002Fstrong> A reset email that is one big banner image with little text reads as evasion. Maintain a healthy text-to-image ratio.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>URL shorteners and mismatched links.\u003C\u002Fstrong> Never use bit.ly-style shorteners in reset links — they hide the destination and are a phishing hallmark. Display URLs must match their \u003Ccode>href\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Link domains that differ from your sending domain.\u003C\u002Fstrong> If you send from \u003Ccode>yourdomain.com\u003C\u002Fcode> but the reset link points to \u003Ccode>app-reset-3847.cloudfront.net\u003C\u002Fcode>, filters flag the mismatch.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Spam-trigger phrases.\u003C\u002Fstrong> \"Click here immediately,\" \"Verify your account now or it will be deleted,\" excessive capitalization, and multiple exclamation marks all raise the score.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Broken HTML.\u003C\u002Fstrong> Malformed or unclosed tags and bloated nested-table markup increase spam scores.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Missing plain-text part.\u003C\u002Fstrong> Always send a \u003Ccode>multipart\u002Falternative\u003C\u002Fcode> message with both HTML and plain text. HTML-only emails look more suspicious.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>A clean reset email structure\u003C\u002Fh3>\n\u003Cp>Here's what a deliverability-friendly password reset email body looks like — clear text, one obvious link on your own domain, and a plain-text fallback:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>Subject: Reset your password\n\nHi Alex,\n\nWe received a request to reset the password for your account.\nClick the link below to choose a new password. This link\nexpires in 30 minutes.\n\nhttps:\u002F\u002Fyourdomain.com\u002Freset?token=eyJhbGciOi...\n\nIf you didn't request this, you can safely ignore this email —\nyour password won't change.\n\n— The YourApp Team\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Note the absence of urgency-baiting language, the link on the sending domain, and the reassuring \"if you didn't request this\" line — which also reduces complaints.\u003C\u002Fp>\n\u003Ch2>Reason 4: Infrastructure and Sending Patterns\u003C\u002Fh2>\n\u003Cp>How you send matters as much as what you send.\u003C\u002Fp>\n\u003Ch3>Sending reset email from your app server\u003C\u002Fh3>\n\u003Cp>Sending SMTP directly from your application server is one of the most common causes of password reset spam. App servers usually run on cloud IPs (AWS, GCP, DigitalOcean) that are widely blocklisted because so much spam originates from them. They also lack the authentication setup, feedback loops, and reputation management a dedicated sending service provides.\u003C\u002Fp>\n\u003Cp>Use a transactional email API or relay built for deliverability instead of raw SMTP from your app.\u003C\u002Fp>\n\u003Ch3>No bounce or complaint handling\u003C\u002Fh3>\n\u003Cp>If you keep emailing addresses that hard-bounce, or ignore spam complaints via feedback loops, your reputation erodes fast. A real sending platform processes bounces and complaints automatically and suppresses bad addresses.\u003C\u002Fp>\n\u003Ch3>TLS and connection quality\u003C\u002Fh3>\n\u003Cp>Mailbox providers expect opportunistic TLS on the SMTP connection. Sending unencrypted, or with misconfigured reverse DNS (PTR records), signals a low-quality sender.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Infrastructure factor\u003C\u002Fth>\n\u003Cth>Spam risk\u003C\u002Fth>\n\u003Cth>Fix\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>SMTP direct from app server\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Use a transactional email API\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>No PTR \u002F reverse DNS\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Set PTR matching your sending hostname\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>No bounce handling\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Automatic suppression lists\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>No TLS on connection\u003C\u002Ftd>\n\u003Ctd>Medium\u003C\u002Ftd>\n\u003Ctd>Enable opportunistic TLS\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Shared IP with spammers\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Dedicated IP or reputable provider\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch2>A Practical Deliverability Checklist for Reset Emails\u003C\u002Fh2>\n\u003Cp>Run through this before your next deploy. Each item maps directly to one of the spam causes above.\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>SPF\u003C\u002Fstrong> — Published, under 10 DNS lookups, ends in \u003Ccode>-all\u003C\u002Fcode>, includes your current ESP.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM\u003C\u002Fstrong> — Signing enabled on the exact domain in your \u003Ccode>From:\u003C\u002Fcode> header; key length 1024-bit minimum (2048 preferred).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC\u003C\u002Fstrong> — Published at \u003Ccode>p=quarantine\u003C\u002Fcode> or \u003Ccode>p=reject\u003C\u002Fcode> with aligned SPF\u002FDKIM.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Dedicated subdomain\u003C\u002Fstrong> — Transactional mail isolated from marketing.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Reverse DNS (PTR)\u003C\u002Fstrong> — Set and matching your sending hostname.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>From address\u003C\u002Fstrong> — A real, monitored address (not \u003Ccode>noreply@\u003C\u002Fcode> on a domain you can't receive replies to). A consistent, recognizable \u003Ccode>From:\u003C\u002Fcode> improves engagement.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Plain-text part\u003C\u002Fstrong> — Every reset email is \u003Ccode>multipart\u002Falternative\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Links on your own domain\u003C\u002Fstrong> — No shorteners, no mismatched destinations.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bounce &amp; complaint handling\u003C\u002Fstrong> — Automatic suppression in place.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Monitoring\u003C\u002Fstrong> — DMARC reports reviewed, blocklist status checked, deliverability tested across Gmail\u002FOutlook\u002FApple.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>How to Test Whether Your Reset Emails Reach the Inbox\u003C\u002Fh2>\n\u003Cp>You can't fix what you don't measure. Use these methods:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Seed-list \u002F inbox-placement testing.\u003C\u002Fstrong> Tools like Mail-Tester, GlockApps, or your provider's seed testing send to a set of monitored inboxes across providers and report where each lands. Mail-Tester also gives you a SpamAssassin score and flags content issues.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Send to your own accounts.\u003C\u002Fstrong> Trigger a real reset to Gmail, Outlook, Yahoo, and iCloud test accounts and check the placement. Cheap and revealing.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Read the headers.\u003C\u002Fstrong> Open the raw message and inspect \u003Ccode>Authentication-Results\u003C\u002Fcode>. You want to see \u003Ccode>spf=pass\u003C\u002Fcode>, \u003Ccode>dkim=pass\u003C\u002Fcode>, and \u003Ccode>dmarc=pass\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cpre>\u003Ccode>Authentication-Results: mx.google.com;\n  dkim=pass header.i=@yourdomain.com;\n  spf=pass smtp.mailfrom=mail.yourdomain.com;\n  dmarc=pass (p=REJECT) header.from=yourdomain.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Three passes is the baseline. If any line shows \u003Ccode>fail\u003C\u002Fcode>, \u003Ccode>softfail\u003C\u002Fcode>, or \u003Ccode>none\u003C\u002Fcode>, you've found your problem.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Monitor DMARC aggregate reports.\u003C\u002Fstrong> They tell you which sources are sending as your domain and whether they authenticate — exposing both misconfigured services and spoofing attempts.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Code Example: Sending a Reset Email via a Transactional API\u003C\u002Fh2>\n\u003Cp>Here's how sending a reset email through a deliverability-focused API looks in practice, compared to raw SMTP. The API handles authentication, reputation, bounce processing, and retries for you.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import requests\n\ndef send_password_reset(email: str, reset_token: str):\n    reset_url = f&quot;https:\u002F\u002Fyourdomain.com\u002Freset?token={reset_token}&quot;\n\n    response = requests.post(\n        &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fmessages&quot;,\n        headers={&quot;Authorization&quot;: &quot;Bearer YOUR_API_KEY&quot;},\n        json={\n            &quot;from&quot;: &quot;YourApp &lt;support@mail.yourdomain.com&gt;&quot;,\n            &quot;to&quot;: email,\n            &quot;subject&quot;: &quot;Reset your password&quot;,\n            &quot;text&quot;: (\n                &quot;We received a request to reset your password.\\n\\n&quot;\n                f&quot;Reset it here (expires in 30 min): {reset_url}\\n\\n&quot;\n                &quot;Didn't request this? You can ignore this email.&quot;\n            ),\n            &quot;html&quot;: render_reset_html(reset_url),\n        },\n        timeout=10,\n    )\n    response.raise_for_status()\n    return response.json()\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The key wins over SMTP-from-app-server: the request goes out from an IP pool with established reputation, the message is automatically DKIM-signed for \u003Ccode>mail.yourdomain.com\u003C\u002Fcode>, bounces and complaints are tracked, and you get a message ID to trace delivery. None of that exists when you \u003Ccode>smtplib.sendmail()\u003C\u002Fcode> from your web server.\u003C\u002Fp>\n\u003Ch2>Common Mistakes That Send Reset Emails to Spam\u003C\u002Fh2>\n\u003Cp>Even experienced teams repeat these. Avoid them.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Using \u003Ccode>~all\u003C\u002Fcode> (soft-fail) in SPF instead of \u003Ccode>-all\u003C\u002Fcode>.\u003C\u002Fstrong> Weakens the authentication signal; switch to hard-fail once you've confirmed all legitimate senders are included.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping DKIM because \"SPF is enough.\"\u003C\u002Fstrong> It isn't. DKIM survives forwarding and is weighted more heavily. Sign every reset email.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Leaving DMARC at \u003Ccode>p=none\u003C\u002Fcode> forever.\u003C\u002Fstrong> Monitoring is the start, not the goal. Move to enforcement so spoofers can't impersonate your reset emails.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending transactional and marketing from the same domain\u002FIP.\u003C\u002Fstrong> One bad campaign tanks your reset deliverability. Separate the streams by subdomain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending SMTP directly from the app server.\u003C\u002Fstrong> Cloud IPs are pre-distrusted. Use a transactional sending service.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\u003Ccode>From: noreply@\u003C\u002Fcode> on an unmonitored domain.\u003C\u002Fstrong> Hurts engagement and means you miss replies. Use a real, monitored sender address.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring bounces and complaints.\u003C\u002Fstrong> Repeatedly mailing dead addresses or spam-complainers destroys reputation. Suppress them automatically.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Urgency-bait subject lines.\u003C\u002Fstrong> \"URGENT: Reset NOW!!!\" is a phishing pattern. Keep it calm: \"Reset your password.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Not testing across providers.\u003C\u002Fstrong> Gmail, Outlook, and Apple filter differently. Test all three before assuming you're fine.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Forgetting to update DNS after switching ESPs.\u003C\u002Fstrong> A provider change without an SPF\u002FDKIM update means instant authentication failure.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>FAQ\u003C\u002Fh2>\n\u003Ch3>Why do my password reset emails go to spam but my marketing emails don't?\u003C\u002Fh3>\n\u003Cp>Usually because the two streams share a domain or IP and the transactional path was set up as an afterthought. Reset emails often get sent directly from the app server (a blocklisted cloud IP) or without proper DKIM signing, while the marketing email goes through a configured ESP. Reset emails also structurally resemble phishing — one urgent link — so they're held to a higher authentication standard. Isolate transactional mail on a dedicated, fully authenticated subdomain.\u003C\u002Fp>\n\u003Ch3>How do I check if my email authentication is set up correctly?\u003C\u002Fh3>\n\u003Cp>Send a test email to a Gmail account, open it, and view the original\u002Fraw source. Look at the \u003Ccode>Authentication-Results\u003C\u002Fcode> header — you want \u003Ccode>spf=pass\u003C\u002Fcode>, \u003Ccode>dkim=pass\u003C\u002Fcode>, and \u003Ccode>dmarc=pass\u003C\u002Fcode>. You can also use Mail-Tester (send to the address it gives you and get a 0–10 score) or check DNS records directly with \u003Ccode>dig TXT yourdomain.com\u003C\u002Fcode> for SPF and \u003Ccode>dig TXT _dmarc.yourdomain.com\u003C\u002Fcode> for DMARC.\u003C\u002Fp>\n\u003Ch3>Does sending password reset emails from my app server hurt deliverability?\u003C\u002Fh3>\n\u003Cp>Yes, significantly. Application servers run on cloud provider IPs (AWS, GCP, DigitalOcean) that are widely blocklisted because so much spam originates from them, and they lack DKIM signing, feedback loops, and reputation management. Route reset emails through a transactional email API or relay built for deliverability instead of calling \u003Ccode>smtplib\u003C\u002Fcode> directly from your web server.\u003C\u002Fp>\n\u003Ch3>What's the single most important fix for password reset email spam?\u003C\u002Fh3>\n\u003Cp>Email authentication — specifically getting SPF, DKIM, and DMARC all passing with proper alignment. Authentication failures are the most common cause of reset emails being junked, especially after Gmail and Yahoo's 2024 bulk-sender requirements. If you fix only one thing, make sure DKIM is signing on the exact domain in your \u003Ccode>From:\u003C\u002Fcode> header and DMARC is published at \u003Ccode>p=quarantine\u003C\u002Fcode> or stricter.\u003C\u002Fp>\n\u003Ch3>Should I use a dedicated IP for transactional emails?\u003C\u002Fh3>\n\u003Cp>It depends on volume. At high, steady volume (tens of thousands of messages a day or more), a properly warmed dedicated IP gives you full control over your reputation. At lower volume, a dedicated IP can actually \u003Cem>hurt\u003C\u002Fem> you because there isn't enough traffic to establish a reputation — a reputable shared pool is better. More important than IP type is separating transactional mail onto its own authenticated subdomain.\u003C\u002Fp>\n\u003Ch3>Why do reset emails sometimes arrive late or not at all?\u003C\u002Fh3>\n\u003Cp>Late or missing reset emails usually trace to one of three things: greylisting (the receiver temporarily defers a first-time sender and accepts on retry), reputation throttling (the provider rate-limits a low-reputation sender), or a soft bounce that your system didn't retry. A transactional email service with automatic retries and good reputation minimizes all three. If emails never arrive, check that they aren't being silently dropped due to a DMARC \u003Ccode>p=reject\u003C\u002Fcode> authentication failure.\u003C\u002Fp>\n\u003Ch3>Will a DMARC policy of p=reject block my legitimate reset emails?\u003C\u002Fh3>\n\u003Cp>Only if those emails fail authentication — which is the point. Before moving to \u003Ccode>p=reject\u003C\u002Fcode>, run at \u003Ccode>p=none\u003C\u002Fcode> and review the aggregate (\u003Ccode>rua\u003C\u002Fcode>) reports to confirm every legitimate sending source passes SPF or DKIM with alignment. Once your reports are clean, \u003Ccode>p=reject\u003C\u002Fcode> blocks spoofers impersonating your reset emails without touching your real mail. Always make this transition gradually: \u003Ccode>none\u003C\u002Fcode> → \u003Ccode>quarantine\u003C\u002Fcode> → \u003Ccode>reject\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>How long does it take to fix reset email deliverability?\u003C\u002Fh3>\n\u003Cp>DNS authentication fixes (SPF, DKIM, DMARC) propagate within minutes to a few hours and improve deliverability almost immediately. Reputation repair takes longer — days to weeks — if your domain or IP was already damaged by spam complaints or blocklisting. Content and infrastructure fixes (clean templates, dedicated subdomain, a proper sending service) take effect on the next send.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Password reset emails go to spam for reasons that are entirely within your control. The pattern is consistent: failed or missing authentication, a fragmented or damaged sender reputation, phishing-like content, and sending infrastructure that mailbox providers distrust by default. Each of these is measurable, and each has a concrete fix.\u003C\u002Fp>\n\u003Cp>Start with authentication — SPF, DKIM, and DMARC passing with alignment solves the majority of password reset email spam cases. Then isolate transactional mail on a dedicated subdomain, clean up your templates, stop sending SMTP directly from your app server, and test inbox placement across Gmail, Outlook, and Apple before you ship. Email deliverability for transactional messages isn't luck; it's the sum of these specific, verifiable decisions.\u003C\u002Fp>\n\u003Cp>For a SaaS product, a reset link that reliably hits the inbox is the difference between a user who logs back in and one who silently leaves. Treat your authentication emails as critical infrastructure, because to your users, they are.\u003C\u002Fp>\n\u003Ch2>Send Reset Emails That Land in the Inbox with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email delivery platform built for exactly this problem. It handles DKIM signing, reputation management, bounce and complaint suppression, and inbox-focused infrastructure out of the box — so your password reset, verification, and magic-link emails reach users instead of their spam folders.\u003C\u002Fp>\n\u003Cp>Built for developers and SaaS teams, Postwing offers a clean API, deliverability monitoring, and authentication guidance, with billing in \u003Cstrong>USDC on Base\u003C\u002Fstrong> — no card required, no recurring lock-in. Point your transactional email through a sending path engineered for the inbox and stop losing users to the spam folder.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","a32e947a-259e-46cc-b95f-dc943b0d05ae","2026-06-24T18:28:18.011497+03:00","2026-07-15T23:42:31.509882+03:00","Why Your Password Reset Emails Go to Spam","Password reset emails go to spam more than any other message. Learn the email deliverability fixes that get reset links into the inbox, every time.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_a32e947a-259e-46cc-b95f-dc943b0d05ae\u002FWhy_Your_Password_Reset_Emails_Go_to_Spam.png","2026-07-02T09:00:00+03:00",[202,203],"email deliverability","password reset email spam",{"id":205,"body":206,"uuid":207,"created_at":208,"updated_at":209,"brand":13,"header":210,"short_body":211,"image":212,"published":17,"published_at":213,"tags":214},8,"\u003Cp>Understanding the difference between \u003Cstrong>transactional vs marketing email\u003C\u002Fstrong> is one of the most consequential technical decisions a SaaS team makes — and one of the most commonly overlooked. The two email types share the same protocol (SMTP), the same inbox, and often the same provider account, but they behave like completely different products. They have different legal obligations, different reputation requirements, different latency expectations, and dramatically different rules for \u003Cstrong>email deliverability\u003C\u002Fstrong>.\u003C\u002Fp>\n\u003Cp>When you mix them up — or worse, send them down the same pipe — your password reset emails start landing in spam, your invoices get delayed, and your sender reputation erodes for everyone. For developers and SaaS founders, getting this distinction right is the foundation of a reliable email system.\u003C\u002Fp>\n\u003Cp>This guide breaks down exactly how transactional email differs from marketing email: the definitions, the technical architecture, the legal framework, the deliverability mechanics, and the practical patterns you should follow. By the end, you'll know how to architect, separate, and monitor both streams so every message reaches the inbox.\u003C\u002Fp>\n\u003Ch2>Transactional vs Marketing Email: The Quick Answer\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Transactional email\u003C\u002Fstrong> is a message triggered by a specific user action or account event, sent to one recipient, containing information they expect and need — a password reset, an order receipt, a verification code, or a shipping notification. \u003Cstrong>Marketing email\u003C\u002Fstrong> is a message sent to a list of recipients to promote, inform, or re-engage — a newsletter, a product announcement, or a promotional campaign.\u003C\u002Fp>\n\u003Cp>The core distinction comes down to \u003Cstrong>trigger, intent, and consent\u003C\u002Fstrong>:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional email\u003C\u002Fstrong> is \u003Cem>one-to-one\u003C\u002Fem>, \u003Cem>event-driven\u003C\u002Fem>, and \u003Cem>expected\u003C\u002Fem>. The user took an action; the email is the response.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Marketing email\u003C\u002Fstrong> is \u003Cem>one-to-many\u003C\u002Fem>, \u003Cem>campaign-driven\u003C\u002Fem>, and \u003Cem>consent-based\u003C\u002Fem>. You decided to reach out; the user must have opted in.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>That single difference cascades into everything else — the infrastructure you build, the laws you must obey, and the deliverability strategy you adopt.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Attribute\u003C\u002Fth>\n\u003Cth>Transactional Email\u003C\u002Fth>\n\u003Cth>Marketing Email\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Trigger\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>User action \u002F system event\u003C\u002Ftd>\n\u003Ctd>Campaign schedule \u002F segment\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Recipients\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Single user (1:1)\u003C\u002Ftd>\n\u003Ctd>Lists \u002F segments (1:many)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Consent required\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>No (implied by action)\u003C\u002Ftd>\n\u003Ctd>Yes (explicit opt-in)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Unsubscribe required\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003Ctd>Yes (legally mandated)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Examples\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Password reset, receipt, OTP, alert\u003C\u002Ftd>\n\u003Ctd>Newsletter, promo, digest, re-engagement\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Open rates (typical)\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>40–60%+\u003C\u002Ftd>\n\u003Ctd>15–25%\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Latency expectation\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Seconds\u003C\u002Ftd>\n\u003Ctd>Minutes to hours\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Volume pattern\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Spiky, real-time\u003C\u002Ftd>\n\u003Ctd>Batched, scheduled\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Primary risk\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Delay = broken UX\u003C\u002Ftd>\n\u003Ctd>Spam complaints = blocklisting\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Sender reputation impact\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Must be pristine\u003C\u002Ftd>\n\u003Ctd>Volatile, list-dependent\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch2>What Counts as a Transactional Email?\u003C\u002Fh2>\n\u003Cp>A transactional email is generated programmatically in response to a user's interaction with your application. It is not promotional, and the recipient has effectively requested it by performing an action. Common categories include:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Authentication\u003C\u002Fstrong>: email verification, one-time passwords (OTP), magic links, two-factor codes, password resets.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Account lifecycle\u003C\u002Fstrong>: welcome emails, account confirmation, email change verification, account deletion notices.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Commerce\u003C\u002Fstrong>: order confirmations, receipts, invoices, refund notices, subscription renewals, payment failures.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Operational notifications\u003C\u002Fstrong>: shipping updates, booking confirmations, security alerts (\"new login from a new device\"), usage limit warnings.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>System events\u003C\u002Fstrong>: export-ready notifications, form submissions, comment replies, mentions.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>The defining test: \u003Cstrong>Would the user be confused, blocked, or alarmed if this email never arrived?\u003C\u002Fstrong> If yes, it's transactional.\u003C\u002Fp>\n\u003Ch3>The \"expected and individual\" test\u003C\u002Fh3>\n\u003Cp>Two conditions must both hold for a message to be genuinely transactional:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>It is expected.\u003C\u002Fstrong> The user took an action that logically results in this email. They clicked \"Reset password,\" made a purchase, or signed up.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>It is individual.\u003C\u002Fstrong> It is addressed to one person, with content specific to them and their action — not a broadcast.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>A \"welcome email\" after signup is transactional. A \"We miss you, here's 20% off\" sent two weeks later is marketing — even though it goes to the same person.\u003C\u002Fp>\n\u003Ch2>What Counts as a Marketing Email?\u003C\u002Fh2>\n\u003Cp>Marketing email is any commercial message whose primary purpose is to promote, advertise, or drive engagement with your product. It includes:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Newsletters and digests\u003C\u002Fstrong> sent on a schedule.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Product announcements\u003C\u002Fstrong> and feature launches.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Promotional offers\u003C\u002Fstrong>, discounts, and seasonal campaigns.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Re-engagement \u002F win-back\u003C\u002Fstrong> sequences for inactive users.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Drip campaigns\u003C\u002Fstrong> and onboarding sequences that go beyond pure account setup.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Surveys and feedback requests\u003C\u002Fstrong> sent to segments.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Marketing email is governed by consent. The recipient must have opted in (or, in some jurisdictions, must not have opted out), and every message must include a clear, working unsubscribe mechanism.\u003C\u002Fp>\n\u003Ch3>The gray zone: lifecycle and onboarding emails\u003C\u002Fh3>\n\u003Cp>Some emails blur the line. An onboarding drip (\"Here are 3 tips to get started\") is technically triggered by signup but is promotional in intent. A best practice is to \u003Cstrong>treat ambiguous emails as marketing\u003C\u002Fstrong> for legal and deliverability purposes: require consent, include an unsubscribe link, and send them through your marketing infrastructure. When in doubt, the safer classification is marketing.\u003C\u002Fp>\n\u003Ch2>The Legal Difference: CAN-SPAM, GDPR, and Consent\u003C\u002Fh2>\n\u003Cp>The most concrete way transactional and marketing email differ is in the law. Regulators draw a hard line, and the obligations are not the same.\u003C\u002Fp>\n\u003Ch3>CAN-SPAM (United States)\u003C\u002Fh3>\n\u003Cp>Under the U.S. \u003Ca href=\"https:\u002F\u002Fwww.ftc.gov\u002Fbusiness-guidance\u002Fresources\u002Fcan-spam-act-compliance-guide-business\">CAN-SPAM Act\u003C\u002Fa>, the rules depend on the message's \u003Cem>primary purpose\u003C\u002Fem>:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Commercial (marketing) messages\u003C\u002Fstrong> must include a valid physical postal address, a clear and conspicuous unsubscribe mechanism, honor opt-outs within 10 business days, and use non-deceptive subject lines and headers.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Transactional or relationship messages\u003C\u002Fstrong> are largely exempt from these requirements. They must not contain false or misleading routing information, but they do \u003Cstrong>not\u003C\u002Fstrong> require an unsubscribe link or postal address.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>If an email mixes transactional and promotional content, CAN-SPAM looks at the \u003Cstrong>primary purpose\u003C\u002Fstrong>. Bolting a promo banner onto a receipt can legally reclassify the whole message as commercial.\u003C\u002Fp>\n\u003Ch3>GDPR and ePrivacy (European Union)\u003C\u002Fh3>\n\u003Cp>Under GDPR and the ePrivacy Directive, \u003Cstrong>marketing email generally requires prior, explicit, opt-in consent\u003C\u002Fstrong>. Transactional email tied to performing a contract (e.g., sending a receipt for a purchase, or a password reset the user requested) typically relies on the \"performance of a contract\" or \"legitimate interest\" legal basis — not marketing consent. But the moment a transactional message includes promotional content, it may require marketing consent.\u003C\u002Fp>\n\u003Ch3>The practical takeaway\u003C\u002Fh3>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Never inject marketing content into a transactional email.\u003C\u002Fstrong> Adding a \"Check out our new plan!\" CTA to a password reset can legally convert it into a commercial message, forcing unsubscribe\u002Fconsent requirements onto it — and signaling to mailbox providers that the message is promotional, which hurts deliverability.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>Keep the streams clean. A receipt is a receipt. A newsletter is a newsletter.\u003C\u002Fp>\n\u003Ch2>How They Differ Technically: Architecture and Infrastructure\u003C\u002Fh2>\n\u003Cp>This is where the transactional vs marketing email distinction matters most to engineers. The two streams have opposing performance and reliability profiles, so they should run on separate infrastructure.\u003C\u002Fp>\n\u003Ch3>Latency and delivery speed\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional email is latency-critical.\u003C\u002Fstrong> An OTP that arrives 90 seconds late is a failed login. A password reset that's delayed is a support ticket. These messages must be sent and delivered in seconds, synchronously triggered by an API call.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Marketing email is throughput-oriented.\u003C\u002Fstrong> A newsletter to 500,000 subscribers can take minutes or hours to fan out; nobody notices if it lands at 9:03 instead of 9:00. It's batched, queued, and rate-limited to protect reputation.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Volume and traffic patterns\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional\u003C\u002Fstrong> traffic is spiky and unpredictable — it follows real user activity. A login spike or a checkout surge produces a corresponding email spike.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Marketing\u003C\u002Fstrong> traffic is bursty by design — you blast a segment all at once on a schedule.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>These patterns interfere with each other. A marketing blast saturating your sending IPs can delay time-sensitive transactional mail unless they're isolated.\u003C\u002Fp>\n\u003Ch3>Separate sending domains and IPs\u003C\u002Fh3>\n\u003Cp>The single most important architectural decision: \u003Cstrong>use separate sending identities (subdomains, and ideally separate IPs\u002FIP pools) for transactional and marketing email.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>A common, robust setup:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Transactional mail from \u003Ccode>notifications.yourapp.com\u003C\u002Fcode> (or \u003Ccode>mail.yourapp.com\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>Marketing mail from \u003Ccode>news.yourapp.com\u003C\u002Fcode> (or \u003Ccode>email.yourapp.com\u003C\u002Fcode>)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>This isolates \u003Cstrong>sender reputation\u003C\u002Fstrong>. If a marketing campaign triggers spam complaints and damages the reputation of \u003Ccode>news.yourapp.com\u003C\u002Fcode>, your critical transactional mail on \u003Ccode>notifications.yourapp.com\u003C\u002Fcode> is unaffected and keeps reaching the inbox.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Concern\u003C\u002Fth>\n\u003Cth>Transactional Stream\u003C\u002Fth>\n\u003Cth>Marketing Stream\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Sending domain\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>notifications.yourapp.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>news.yourapp.com\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>IP strategy\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Dedicated\u002Fpooled, warm, stable\u003C\u002Ftd>\n\u003Ctd>Separate pool, list-warmed\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Authentication\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>SPF, DKIM, DMARC (strict)\u003C\u002Ftd>\n\u003Ctd>SPF, DKIM, DMARC (strict)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Sending model\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Synchronous API, real-time\u003C\u002Ftd>\n\u003Ctd>Batched, scheduled, throttled\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Retry logic\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Aggressive, fast\u003C\u002Ftd>\n\u003Ctd>Tolerant, slow\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Provider\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Email API (e.g., Postwing)\u003C\u002Ftd>\n\u003Ctd>ESP \u002F campaign tool\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch2>Email Deliverability: Where the Difference Bites Hardest\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Email deliverability\u003C\u002Fstrong> — whether your message lands in the inbox versus spam or oblivion — is governed by sender reputation, authentication, and recipient engagement. Transactional and marketing email earn and lose reputation differently.\u003C\u002Fp>\n\u003Ch3>Authentication is mandatory for both\u003C\u002Fh3>\n\u003Cp>Regardless of type, every sending domain needs the three pillars of email authentication:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF (Sender Policy Framework)\u003C\u002Fstrong> — authorizes which servers may send for your domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM (DomainKeys Identified Mail)\u003C\u002Fstrong> — cryptographically signs messages so receivers can verify integrity and origin.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC (Domain-based Message Authentication, Reporting &amp; Conformance)\u003C\u002Fstrong> — tells receivers what to do with mail that fails SPF\u002FDKIM, and gives you reporting.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A representative DNS setup looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">; SPF — authorize your provider's sending servers\nnotifications.yourapp.com.  TXT  &quot;v=spf1 include:spf.postwing.app -all&quot;\n\n; DKIM — public key published by your provider\npw1._domainkey.notifications.yourapp.com.  CNAME  pw1.dkim.postwing.app.\n\n; DMARC — policy + aggregate reports\n_dmarc.yourapp.com.  TXT  &quot;v=DMARC1; p=quarantine; rua=mailto:dmarc@yourapp.com; adkim=s; aspf=s&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>As of 2024, Gmail and Yahoo require SPF, DKIM, \u003Cstrong>and\u003C\u002Fstrong> DMARC for bulk senders — and increasingly scrutinize all senders. Without these, both transactional and marketing email get filtered.\u003C\u002Fp>\n\u003Ch3>Why transactional reputation must be pristine\u003C\u002Fh3>\n\u003Cp>Transactional email enjoys naturally high engagement: open rates of 40–60% or higher, because users \u003Cem>want\u003C\u002Fem> these messages. That engagement signals quality to mailbox providers, so a clean transactional stream tends to maintain excellent inbox placement.\u003C\u002Fp>\n\u003Cp>But that reputation is fragile. Because transactional volume reflects real activity, anything that looks promotional — marketing content, unexpected bulk, or complaints — degrades it fast. And the cost is acute: a single hour of transactional mail in the spam folder means users can't log in or check out.\u003C\u002Fp>\n\u003Ch3>Why marketing reputation is volatile\u003C\u002Fh3>\n\u003Cp>Marketing email lives and dies by list hygiene and engagement:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Spam complaints\u003C\u002Fstrong> are the killer. Mailbox providers track complaint rates; above ~0.1–0.3%, deliverability collapses.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bounces\u003C\u002Fstrong> from stale lists signal poor hygiene.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Engagement decay\u003C\u002Fstrong> — sending to people who never open — trains filters to route you to spam or \"Promotions.\"\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>This is precisely why you isolate it. A marketing list going stale should never be able to drag your login emails into the spam folder.\u003C\u002Fp>\n\u003Ch3>Inbox placement: Primary vs Promotions tab\u003C\u002Fh3>\n\u003Cp>Gmail's tabs reflect the divide. Transactional mail typically lands in the \u003Cstrong>Primary\u003C\u002Fstrong> tab (high engagement, expected). Marketing mail often lands in \u003Cstrong>Promotions\u003C\u002Fstrong> — which is fine and expected for campaigns, but a disaster for a password reset. If your OTP shows up under \"Promotions,\" users won't find it. Keeping streams separate, content clean, and reputation high keeps transactional mail in Primary.\u003C\u002Fp>\n\u003Ch2>Code Example: Sending a Transactional Email\u003C\u002Fh2>\n\u003Cp>Transactional email is sent synchronously via an API in direct response to an event. Here's a minimal example using a transactional email API:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import requests\n\ndef send_password_reset(user_email: str, reset_token: str) -&gt; None:\n    &quot;&quot;&quot;Triggered the instant a user clicks 'Reset password'.&quot;&quot;&quot;\n    response = requests.post(\n        &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend&quot;,\n        headers={&quot;Authorization&quot;: &quot;Bearer YOUR_API_KEY&quot;},\n        json={\n            &quot;from&quot;: &quot;Acme &lt;no-reply@notifications.yourapp.com&gt;&quot;,\n            &quot;to&quot;: user_email,\n            &quot;subject&quot;: &quot;Reset your password&quot;,\n            &quot;html&quot;: (\n                f&quot;&lt;p&gt;Click the link below to reset your password. &quot;\n                f&quot;This link expires in 30 minutes.&lt;\u002Fp&gt;&quot;\n                f'&lt;p&gt;&lt;a href=&quot;https:\u002F\u002Fyourapp.com\u002Freset?token={reset_token}&quot;&gt;'\n                f&quot;Reset password&lt;\u002Fa&gt;&lt;\u002Fp&gt;&quot;\n            ),\n            # No unsubscribe link — this is transactional and expected.\n            &quot;tags&quot;: [&quot;password-reset&quot;, &quot;transactional&quot;],\n        },\n        timeout=10,\n    )\n    response.raise_for_status()\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Note what's \u003Cem>absent\u003C\u002Fem>: no promotional banner, no \"while you're here\" upsell, no marketing footer. The message does exactly one job, on a dedicated transactional subdomain, with a strict timeout because latency matters.\u003C\u002Fp>\n\u003Cp>Contrast this with a marketing send, which is batched and \u003Cstrong>must\u003C\u002Fstrong> include consent handling and an unsubscribe header:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">def send_newsletter(subscribers: list[str], campaign_html: str) -&gt; None:\n    &quot;&quot;&quot;Sent on a schedule to opted-in subscribers only.&quot;&quot;&quot;\n    for batch in chunk(subscribers, size=500):  # throttle to protect reputation\n        requests.post(\n            &quot;https:\u002F\u002Fapi.your-esp.com\u002Fv1\u002Fcampaigns\u002Fsend&quot;,\n            headers={&quot;Authorization&quot;: &quot;Bearer YOUR_ESP_KEY&quot;},\n            json={\n                &quot;from&quot;: &quot;Acme News &lt;hello@news.yourapp.com&gt;&quot;,  # marketing subdomain\n                &quot;to&quot;: batch,\n                &quot;subject&quot;: &quot;What's new this month at Acme&quot;,\n                &quot;html&quot;: campaign_html,\n                # Legally required for marketing:\n                &quot;headers&quot;: {&quot;List-Unsubscribe&quot;: &quot;&lt;https:\u002F\u002Fyourapp.com\u002Funsub&gt;&quot;},\n            },\n            timeout=30,\n        )\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The differences in code are the differences in philosophy: dedicated subdomains, presence\u002Fabsence of unsubscribe, real-time vs batched, strict vs lenient timeouts.\u003C\u002Fp>\n\u003Ch2>Practical Examples: Classifying Real Messages\u003C\u002Fh2>\n\u003Cp>Apply the rules to concrete cases:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>\"Your verification code is 489210.\"\u003C\u002Fstrong> → Transactional. Triggered by login, expected, individual, time-critical. No unsubscribe.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"Your order #4821 has shipped.\"\u003C\u002Fstrong> → Transactional. Triggered by purchase, expected, individual.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"Here's 15% off your next order!\"\u003C\u002Fstrong> → Marketing. Promotional intent, requires consent and unsubscribe.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"You left items in your cart\"\u003C\u002Fstrong> (abandoned cart) → Marketing (commercial purpose) in most jurisdictions; send via marketing infrastructure with unsubscribe.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"Your subscription payment failed.\"\u003C\u002Fstrong> → Transactional. Account-critical, expected, individual.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"We've missed you — come back!\"\u003C\u002Fstrong> → Marketing. Re-engagement campaign.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"New login to your account from Chrome on macOS.\"\u003C\u002Fstrong> → Transactional. Security alert, expected, individual.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>\"Your invoice for May is ready.\"\u003C\u002Fstrong> → Transactional. Billing relationship, expected.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Common Mistakes to Avoid\u003C\u002Fh2>\n\u003Ch3>1. Sending both types from the same domain and IP\u003C\u002Fh3>\n\u003Cp>The most damaging mistake. When a marketing campaign tanks your sender reputation, it drags down your password resets and receipts with it. \u003Cstrong>Always separate sending subdomains and IP pools.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3>2. Injecting marketing content into transactional emails\u003C\u002Fh3>\n\u003Cp>Adding \"Upgrade now!\" to a receipt is tempting — those emails have great open rates. But it can legally reclassify the message as commercial (triggering CAN-SPAM\u002FGDPR obligations) and trains spam filters to see your transactional stream as promotional. Keep it clean.\u003C\u002Fp>\n\u003Ch3>3. Using a marketing ESP for time-critical transactional mail\u003C\u002Fh3>\n\u003Cp>Campaign tools optimize for batch throughput and engagement tracking, not low-latency single sends. An OTP routed through a marketing queue may arrive too late. Use a dedicated \u003Cstrong>transactional email\u003C\u002Fstrong> API for event-driven mail.\u003C\u002Fp>\n\u003Ch3>4. Skipping authentication on the transactional domain\u003C\u002Fh3>\n\u003Cp>Teams sometimes lock down SPF\u002FDKIM\u002FDMARC for marketing and forget the transactional subdomain. Without authentication, your verification emails get filtered. Authenticate every sending identity.\u003C\u002Fp>\n\u003Ch3>5. Treating onboarding drips as transactional\u003C\u002Fh3>\n\u003Cp>A \"3 tips to get started\" sequence feels account-related but is promotional. Sending it without consent or an unsubscribe link risks compliance violations and spam complaints. When in doubt, classify as marketing.\u003C\u002Fp>\n\u003Ch3>6. Ignoring deliverability monitoring\u003C\u002Fh3>\n\u003Cp>Both streams need monitoring — bounce rates, complaint rates, DMARC reports, and inbox placement. You can't fix a deliverability problem you can't see. Watch the transactional stream especially closely; failures there break core product flows.\u003C\u002Fp>\n\u003Ch3>7. Not setting up a feedback loop or suppression list\u003C\u002Fh3>\n\u003Cp>Failing to honor unsubscribes and complaints (suppression) on the marketing side guarantees rising complaint rates and eventual blocklisting.\u003C\u002Fp>\n\u003Ch2>FAQ: Transactional vs Marketing Email\u003C\u002Fh2>\n\u003Ch3>What is the main difference between transactional and marketing email?\u003C\u002Fh3>\n\u003Cp>The main difference is the trigger and consent model. Transactional email is sent one-to-one in response to a specific user action (a password reset, receipt, or verification code) and is expected, so it doesn't require consent or an unsubscribe link. Marketing email is sent one-to-many to promote your product and legally requires opt-in consent and a working unsubscribe mechanism.\u003C\u002Fp>\n\u003Ch3>Can I send marketing and transactional emails from the same domain?\u003C\u002Fh3>\n\u003Cp>You can, but you shouldn't. Best practice is to use separate sending subdomains (e.g., \u003Ccode>notifications.yourapp.com\u003C\u002Fcode> for transactional and \u003Ccode>news.yourapp.com\u003C\u002Fcode> for marketing) and ideally separate IP pools. This isolates sender reputation so a marketing campaign's spam complaints never push your critical transactional emails into the spam folder.\u003C\u002Fp>\n\u003Ch3>Do transactional emails need an unsubscribe link?\u003C\u002Fh3>\n\u003Cp>No. Under CAN-SPAM and similar laws, purely transactional or relationship messages are exempt from unsubscribe requirements because the recipient effectively requested them. However, if you add promotional content to a transactional email, it can be reclassified as commercial, which then requires an unsubscribe link and other compliance steps.\u003C\u002Fp>\n\u003Ch3>Why do my password reset emails go to spam?\u003C\u002Fh3>\n\u003Cp>Usually it's a deliverability problem: missing or misconfigured SPF\u002FDKIM\u002FDMARC authentication, a sending domain whose reputation was damaged by marketing mail sharing the same infrastructure, or promotional content in transactional messages. Separating your transactional stream onto a clean, fully authenticated subdomain almost always fixes it.\u003C\u002Fp>\n\u003Ch3>Which type of email has better deliverability?\u003C\u002Fh3>\n\u003Cp>Transactional email typically achieves better inbox placement because recipients expect and engage with it, producing open rates of 40–60%+ that signal quality to mailbox providers. But that advantage only holds if the stream stays clean — isolated from marketing, fully authenticated, and free of promotional content.\u003C\u002Fp>\n\u003Ch3>Is an order confirmation a transactional or marketing email?\u003C\u002Fh3>\n\u003Cp>An order confirmation is transactional. It's triggered by a specific purchase, addressed to one individual, and contains information the buyer expects and needs. Adding cross-sell or promotional content (\"You may also like…\") risks reclassifying it as commercial, so keep upsells minimal or in a separate marketing message.\u003C\u002Fp>\n\u003Ch3>What happens if I mix transactional and marketing content in one email?\u003C\u002Fh3>\n\u003Cp>Regulators judge the email by its \"primary purpose.\" If promotional content dominates or is prominent, the message may be legally treated as commercial — requiring an unsubscribe link, a physical address, and consent. It also signals to spam filters that your transactional stream is promotional, hurting deliverability for all your account-critical emails.\u003C\u002Fp>\n\u003Ch3>Do I need different email providers for each type?\u003C\u002Fh3>\n\u003Cp>Not strictly, but the workloads are different. Use a transactional email API optimized for low-latency, real-time single sends (like Postwing) for account-critical mail, and a campaign-focused ESP for batched marketing with list management, segmentation, and unsubscribe handling. Many teams run both, with strictly separated domains.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>The distinction between \u003Cstrong>transactional vs marketing email\u003C\u002Fstrong> isn't pedantic — it's architectural, legal, and reputational. Transactional email is event-driven, expected, individual, and latency-critical; marketing email is campaign-driven, consent-based, and throughput-oriented. Treating them as one system is how password resets end up in spam and sender reputations collapse.\u003C\u002Fp>\n\u003Cp>The takeaways for any SaaS team:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Separate the streams.\u003C\u002Fstrong> Different subdomains, ideally different IP pools, so reputation stays isolated.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authenticate everything.\u003C\u002Fstrong> SPF, DKIM, and DMARC on every sending identity — non-negotiable for modern \u003Cstrong>email deliverability\u003C\u002Fstrong>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep transactional emails clean.\u003C\u002Fstrong> No marketing content, no upsells, no unsubscribe link — just the job the user expects, delivered in seconds.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Respect consent and unsubscribe\u003C\u002Fstrong> on the marketing side, and monitor complaint and bounce rates relentlessly.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Classify ambiguous emails as marketing\u003C\u002Fstrong> when in doubt — it's the safer legal and deliverability default.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Get this right and both streams thrive: your campaigns reach engaged subscribers, and your critical transactional mail lands in the inbox every single time.\u003C\u002Fp>\n\u003Ch2>Reach the Inbox with Postwing\u003C\u002Fh2>\n\u003Cp>Postwing is a \u003Cstrong>transactional email API built for developers\u003C\u002Fstrong> who need their password resets, OTPs, receipts, and alerts to arrive in seconds — not minutes, and never the spam folder. We focus exclusively on the transactional stream: low-latency sends, a clean reputation architecture, isolated sending domains, and SPF\u002FDKIM\u002FDMARC set up correctly from day one.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Built for transactional speed\u003C\u002Fstrong> — real-time, synchronous API designed for account-critical mail.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Deliverability by default\u003C\u002Fstrong> — guided domain authentication and reputation isolation so your transactional email stays in the inbox.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Developer-first\u003C\u002Fstrong> — a clean REST API, clear docs, and SDKs you can integrate in minutes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Pay with USDC\u003C\u002Fstrong> — top up your balance with on-chain USDC (on Base), no credit card required, no recurring lock-in.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Stop letting marketing mail jeopardize your most important emails. \u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing\u003C\u002Fa> and give your transactional email the dedicated, deliverable infrastructure it deserves.\u003C\u002Fp>","a2618001-d8ec-4e36-9466-3f2d04e88494","2026-06-24T18:28:18.006867+03:00","2026-07-15T23:41:43.213650+03:00","How Transactional Email Differs from Marketing Email","Transactional vs marketing email: learn the technical, legal, and deliverability differences so your password resets and receipts always hit the inbox.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_a2618001-d8ec-4e36-9466-3f2d04e88494\u002FHow_Transactional_Email_Differs_from_Ma_ALA9cRH.png","2026-06-30T09:00:00+03:00",[215,202,216,217],"transactional vs marketing email","transactional email","marketing email",{"id":219,"body":220,"uuid":221,"created_at":222,"updated_at":223,"brand":13,"header":224,"short_body":225,"image":5,"published":17,"published_at":226,"tags":227},7,"\u003Cp>When you need to send email from an application, the \u003Cstrong>SMTP vs API\u003C\u002Fstrong> decision is one of the first architectural choices you'll make — and it has lasting consequences for deliverability, latency, security, and how much code you have to maintain. SMTP is the decades-old protocol that moves nearly all email across the internet. A modern \u003Cstrong>email API\u003C\u002Fstrong> is an HTTP-based abstraction layered on top of that same infrastructure. Both can deliver the same message to the same inbox, but they behave very differently under load, fail in different ways, and demand different things from your stack.\u003C\u002Fp>\n\u003Cp>This guide breaks down the \u003Cstrong>smtp vs api\u003C\u002Fstrong> question for developers, SaaS founders, and CTOs who are wiring email into a product. We'll cover how each method works, where each one wins, real code examples for both, a side-by-side comparison table, common mistakes that quietly hurt deliverability, and an FAQ optimized for the questions engineers actually ask.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Quick answer:\u003C\u002Fstrong> For transactional email (password resets, receipts, OTP codes, notifications), an \u003Cstrong>email API over HTTP is the better default\u003C\u002Fstrong> for most modern applications — it's faster to integrate, easier to secure, and returns rich delivery metadata. Use \u003Cstrong>SMTP\u003C\u002Fstrong> when you need protocol-level compatibility with legacy systems, third-party software that only speaks SMTP, or a drop-in relay you don't want to wrap in custom code.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>What Is SMTP?\u003C\u002Fh2>\n\u003Cp>SMTP (Simple Mail Transfer Protocol) is the standard, defined in \u003Ca href=\"https:\u002F\u002Fdatatracker.ietf.org\u002Fdoc\u002Fhtml\u002Frfc5321\">RFC 5321\u003C\u002Fa>, that mail servers use to hand messages to one another. It dates back to 1982 and is the universal language of email transport. When your app \"sends email over SMTP,\" it opens a TCP connection to a mail server (a relay or \u003Cstrong>SMTP provider\u003C\u002Fstrong>), authenticates, and conducts a short conversation: \u003Ccode>HELO\u003C\u002Fcode>, \u003Ccode>MAIL FROM\u003C\u002Fcode>, \u003Ccode>RCPT TO\u003C\u002Fcode>, \u003Ccode>DATA\u003C\u002Fcode>, then the message body, then \u003Ccode>QUIT\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>Key characteristics of SMTP:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Stateful, connection-based.\u003C\u002Fstrong> Each send involves a handshake and a multi-step dialogue over a persistent connection.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ports 587 (submission, STARTTLS), 465 (implicit TLS), and 25 (server-to-server).\u003C\u002Fstrong> Port 25 is blocked outbound on most cloud providers to fight spam.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Universally supported.\u003C\u002Fstrong> Every language, framework, mail client, and off-the-shelf CMS knows how to speak SMTP.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Minimal feedback.\u003C\u002Fstrong> SMTP tells you whether the relay \u003Cem>accepted\u003C\u002Fem> the message — not whether it landed in the inbox, was opened, or bounced later.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Because it's a transport protocol rather than a product API, SMTP gives you portability: point your app at a different host and credentials and you've switched providers without changing application logic.\u003C\u002Fp>\n\u003Ch2>What Is an Email API?\u003C\u002Fh2>\n\u003Cp>An \u003Cstrong>email API\u003C\u002Fstrong> is an HTTP\u002FHTTPS interface — almost always RESTful and JSON-based — that you call to send mail. Instead of negotiating a stateful SMTP dialogue, you make a single authenticated \u003Ccode>POST\u003C\u002Fcode> request with the recipient, subject, and body in the payload. The provider's infrastructure handles the actual SMTP delivery to the recipient's mail server on your behalf.\u003C\u002Fp>\n\u003Cp>A \u003Cstrong>transactional email API\u003C\u002Fstrong> is the category built specifically for app-generated, one-to-one messages triggered by user actions — sign-up confirmations, receipts, alerts — as opposed to bulk marketing campaigns. These APIs are optimized for low latency, high throughput, and detailed per-message delivery tracking.\u003C\u002Fp>\n\u003Cp>Key characteristics of an email API:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Stateless HTTP requests.\u003C\u002Fstrong> One request, one response. No long-lived connection to manage.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Rich JSON responses.\u003C\u002Fstrong> You get a message ID, queue status, and often validation errors synchronously.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Webhooks for events.\u003C\u002Fstrong> Delivered, bounced, opened, clicked, complained — pushed to your endpoint in near real time.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Built-in features.\u003C\u002Fstrong> Templating, scheduling, suppression lists, idempotency keys, and analytics are first-class.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>In short: SMTP is a protocol you talk to; an email API is a service you call.\u003C\u002Fp>\n\u003Ch2>SMTP vs API: Head-to-Head Comparison\u003C\u002Fh2>\n\u003Cp>Here is the core \u003Cstrong>smtp vs api\u003C\u002Fstrong> comparison across the dimensions that matter most when you're shipping a product.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Dimension\u003C\u002Fth>\n\u003Cth>SMTP\u003C\u002Fth>\n\u003Cth>Email API (HTTP)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Transport\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Stateful TCP, multi-step handshake\u003C\u002Ftd>\n\u003Ctd>Stateless HTTPS request\u002Fresponse\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Integration speed\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Configure host\u002Fport\u002Fcredentials; library handles protocol\u003C\u002Ftd>\n\u003Ctd>One HTTP call; SDK optional\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Latency per send\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Higher — handshake + round trips per connection\u003C\u002Ftd>\n\u003Ctd>Lower — single request, connection pooling on provider side\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Throughput at scale\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Limited by connection setup and concurrency tuning\u003C\u002Ftd>\n\u003Ctd>Scales horizontally; provider handles fan-out\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Delivery feedback\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Accept\u002Freject at handshake only\u003C\u002Ftd>\n\u003Ctd>Synchronous status + async webhooks (delivered, bounced, opened)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Security\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>SMTP AUTH credentials, TLS via STARTTLS\u003C\u002Ftd>\n\u003Ctd>API keys\u002Ftokens, HTTPS, scoped permissions, easy rotation\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Firewall friendliness\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Ports 587\u002F465\u002F25 often blocked or throttled\u003C\u002Ftd>\n\u003Ctd>Port 443 — almost never blocked\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Features\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Transport only\u003C\u002Ftd>\n\u003Ctd>Templates, scheduling, suppression, analytics, idempotency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Portability\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>High — any SMTP host is a drop-in\u003C\u002Ftd>\n\u003Ctd>Tied to provider API shape (mitigated by SDKs)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Best for\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Legacy apps, third-party software, simple relays\u003C\u002Ftd>\n\u003Ctd>Modern SaaS, high-volume transactional mail, observability\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The headline takeaway: SMTP optimizes for \u003Cstrong>compatibility and portability\u003C\u002Fstrong>, while an email API optimizes for \u003Cstrong>speed, observability, and developer ergonomics\u003C\u002Fstrong>.\u003C\u002Fp>\n\u003Ch2>How SMTP Sending Works in Practice\u003C\u002Fh2>\n\u003Cp>Most developers never write raw SMTP — a library handles the protocol dialogue. Here's a minimal example in Python using the standard library:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import smtplib\nfrom email.message import EmailMessage\n\nmsg = EmailMessage()\nmsg[&quot;From&quot;] = &quot;noreply@yourapp.com&quot;\nmsg[&quot;To&quot;] = &quot;user@example.com&quot;\nmsg[&quot;Subject&quot;] = &quot;Reset your password&quot;\nmsg.set_content(&quot;Click the link to reset your password: https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;)\n\n# Connect over the submission port with STARTTLS\nwith smtplib.SMTP(&quot;smtp.postwing.app&quot;, 587) as server:\n    server.starttls()\n    server.login(&quot;smtp_username&quot;, &quot;smtp_password&quot;)\n    server.send_message(msg)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>And in Node.js using Nodemailer, the de facto SMTP client:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import nodemailer from &quot;nodemailer&quot;;\n\nconst transporter = nodemailer.createTransport({\n  host: &quot;smtp.postwing.app&quot;,\n  port: 587,\n  secure: false, \u002F\u002F STARTTLS upgrades the connection\n  auth: {\n    user: &quot;smtp_username&quot;,\n    pass: &quot;smtp_password&quot;,\n  },\n});\n\nawait transporter.sendMail({\n  from: &quot;noreply@yourapp.com&quot;,\n  to: &quot;user@example.com&quot;,\n  subject: &quot;Reset your password&quot;,\n  text: &quot;Click to reset: https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;,\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Notice what you \u003Cem>don't\u003C\u002Fem> get back: a reliable answer to \"did this reach the inbox?\" The \u003Ccode>send_message\u003C\u002Fcode> call succeeds as long as the relay accepts the message. A bounce that happens later — invalid mailbox, full inbox, spam rejection — arrives asynchronously as a bounce email or not at all unless your provider exposes it elsewhere.\u003C\u002Fp>\n\u003Ch2>How an Email API Send Works in Practice\u003C\u002Fh2>\n\u003Cp>With a \u003Cstrong>transactional email API\u003C\u002Fstrong>, the same password-reset email becomes a single HTTP request. Here's a raw \u003Ccode>curl\u003C\u002Fcode> example so you can see exactly what's on the wire:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">curl -X POST https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend \\\n  -H &quot;Authorization: Bearer $POSTWING_API_KEY&quot; \\\n  -H &quot;Content-Type: application\u002Fjson&quot; \\\n  -d '{\n    &quot;from&quot;: &quot;noreply@yourapp.com&quot;,\n    &quot;to&quot;: &quot;user@example.com&quot;,\n    &quot;subject&quot;: &quot;Reset your password&quot;,\n    &quot;text&quot;: &quot;Click to reset: https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;\n  }'\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The response comes back immediately with a message identifier you can store and correlate with webhook events later:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;id&quot;: &quot;msg_9f8a7b6c5d4e&quot;,\n  &quot;status&quot;: &quot;queued&quot;,\n  &quot;to&quot;: &quot;user@example.com&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The same call in Python:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import requests\n\nresp = requests.post(\n    &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend&quot;,\n    headers={&quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;},\n    json={\n        &quot;from&quot;: &quot;noreply@yourapp.com&quot;,\n        &quot;to&quot;: &quot;user@example.com&quot;,\n        &quot;subject&quot;: &quot;Reset your password&quot;,\n        &quot;text&quot;: &quot;Click to reset: https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;,\n    },\n)\nresp.raise_for_status()\nmessage_id = resp.json()[&quot;id&quot;]\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Two things stand out. First, there's no connection handshake to tune — port 443 over HTTPS just works, even in locked-down cloud environments. Second, you get a \u003Ccode>message_id\u003C\u002Fcode> synchronously, which you can use to track the message's lifecycle through webhooks:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;event&quot;: &quot;delivered&quot;,\n  &quot;message_id&quot;: &quot;msg_9f8a7b6c5d4e&quot;,\n  &quot;timestamp&quot;: &quot;2026-06-24T10:32:11Z&quot;,\n  &quot;recipient&quot;: &quot;user@example.com&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>That observability loop — send, then receive delivered\u002Fbounced\u002Fcomplained events — is the single biggest practical difference in the \u003Cstrong>smtp vs api\u003C\u002Fstrong> comparison, and it's why most modern stacks lean toward the API.\u003C\u002Fp>\n\u003Ch2>When to Choose SMTP\u003C\u002Fh2>\n\u003Cp>SMTP is still the right call in several concrete situations:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Third-party or off-the-shelf software.\u003C\u002Fstrong> WordPress, GitLab, Jira, monitoring tools, and most CMS platforms ship with SMTP configuration fields and nothing else. You can't bolt an HTTP API onto them without a plugin.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Legacy applications.\u003C\u002Fstrong> If your codebase already sends through SMTP and works, rewriting to an API may not be worth the churn — though many providers let you keep SMTP while gaining their dashboard.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Provider portability is a hard requirement.\u003C\u002Fstrong> SMTP credentials are interchangeable. If you must be able to swap providers by changing config alone, SMTP avoids vendor-specific API shapes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>You want a single relay for many disparate systems.\u003C\u002Fstrong> Pointing a fleet of heterogeneous services at one SMTP endpoint can be simpler than integrating each with an API.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Importantly, choosing SMTP doesn't mean running your own mail server. Self-hosting an MTA like Postfix means managing IP reputation, warm-up, blocklists, and feedback loops — a full-time job. Using a managed \u003Cstrong>SMTP provider\u003C\u002Fstrong> gives you SMTP's compatibility with the provider's deliverability infrastructure.\u003C\u002Fp>\n\u003Ch2>When to Choose an Email API\u003C\u002Fh2>\n\u003Cp>An \u003Cstrong>email API\u003C\u002Fstrong> is the better default for most new, application-driven email, especially:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional email at scale.\u003C\u002Fstrong> OTPs, receipts, alerts, and notifications benefit from low latency and high throughput. A \u003Cstrong>transactional email API\u003C\u002Fstrong> is purpose-built for this.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>You need delivery observability.\u003C\u002Fstrong> If you must know whether a password reset reached the user — for support, compliance, or product logic — webhooks and per-message status are essential.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Cloud-native and serverless deployments.\u003C\u002Fstrong> Stateless HTTP plays well with Lambda, Cloud Functions, and edge runtimes where long-lived SMTP connections are awkward and port 25\u002F587 may be blocked.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>You want built-in templating, suppression, and idempotency.\u003C\u002Fstrong> APIs bundle features that you'd otherwise build and maintain yourself.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Security-conscious teams.\u003C\u002Fstrong> Scoped, rotatable API keys are easier to manage and audit than shared SMTP credentials.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For a SaaS product sending verification emails, receipts, and alerts, an email API typically means less code, fewer moving parts, and far better visibility into what's actually happening to your mail.\u003C\u002Fp>\n\u003Ch2>Deliverability: Does SMTP vs API Even Matter?\u003C\u002Fh2>\n\u003Cp>A crucial point that's often misunderstood: \u003Cstrong>the choice between SMTP and API does not, by itself, change inbox placement.\u003C\u002Fstrong> Both methods ultimately hand your message to a mail server via SMTP, and inbox placement is determined by authentication and reputation, not transport choice.\u003C\u002Fp>\n\u003Cp>What actually drives deliverability:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF, DKIM, and DMARC.\u003C\u002Fstrong> These DNS-based standards prove you're authorized to send from your domain. Per \u003Ca href=\"https:\u002F\u002Fblog.google\u002Fproducts\u002Fgmail\u002Fgmail-security-authentication-spam-protection\u002F\">Google and Yahoo's 2024 bulk-sender requirements\u003C\u002Fa>, authenticated mail is effectively mandatory. A good provider configures and signs these for you whether you use SMTP or API.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending IP and domain reputation.\u003C\u002Fstrong> Built over time through consistent volume, low complaints, and low bounces.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>List hygiene.\u003C\u002Fstrong> Sending to invalid or unengaged addresses tanks reputation fast — which is why suppression lists matter.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Content and engagement.\u003C\u002Fstrong> Spam-trigger content and low open rates hurt placement.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>So when comparing \u003Cstrong>smtp vs api\u003C\u002Fstrong>, evaluate the \u003Cem>provider's\u003C\u002Fem> deliverability infrastructure — their authentication handling, reputation management, and bounce\u002Fcomplaint processing — not the transport method. The API's advantage isn't better inboxing; it's better \u003Cem>visibility\u003C\u002Fem> into deliverability through webhooks and analytics, which helps you fix problems faster.\u003C\u002Fp>\n\u003Ch2>Performance and Scale\u003C\u002Fh2>\n\u003Cp>At low volume, the \u003Cstrong>smtp vs api\u003C\u002Fstrong> performance gap is negligible — both send a handful of emails per minute without breaking a sweat. The difference shows up under load.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SMTP\u003C\u002Fstrong> requires a TCP handshake and a multi-command dialogue per connection. To scale, you pool and reuse connections and tune concurrency carefully. Misconfigured pools cause timeouts and dropped sends during spikes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>An email API\u003C\u002Fstrong> pushes that complexity to the provider. You fire stateless HTTPS requests — easy to parallelize, retry, and rate-limit — and the provider handles connection management and fan-out to recipient servers.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For bursty, spiky transactional workloads (think: a product launch triggering thousands of welcome emails in minutes), the API model generally scales with less tuning. If you need to send a single batch to many recipients, many APIs also offer a batch endpoint that's far more efficient than opening one SMTP connection per message.\u003C\u002Fp>\n\u003Ch2>Security Comparison\u003C\u002Fh2>\n\u003Cp>Security is an underrated dimension of the \u003Cstrong>smtp vs api\u003C\u002Fstrong> decision.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>SMTP\u003C\u002Fstrong> uses a username\u002Fpassword (SMTP AUTH) over a TLS-encrypted connection. The credentials are long-lived, often shared across services, and grant full send access. Rotating them means updating every system that uses them.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>An email API\u003C\u002Fstrong> uses bearer tokens or API keys that can be:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Scoped\u003C\u002Fstrong> to specific permissions (send-only, no account access).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Rotated\u003C\u002Fstrong> instantly without touching SMTP config across services.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Audited\u003C\u002Fstrong> per key, so you can see which integration sent what.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Revoked\u003C\u002Fstrong> individually if one leaks.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For teams with security and compliance requirements, scoped API keys over HTTPS are easier to govern than shared SMTP credentials. Either way, always store secrets in environment variables or a secrets manager — never commit them to source control.\u003C\u002Fp>\n\u003Ch2>Common Mistakes Developers Make\u003C\u002Fh2>\n\u003Cp>Whichever side of the \u003Cstrong>smtp vs api\u003C\u002Fstrong> decision you land on, these mistakes quietly damage deliverability and reliability:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Skipping SPF, DKIM, and DMARC.\u003C\u002Fstrong> The single most common cause of emails landing in spam. Authentication is non-negotiable for both SMTP and API sending.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Hardcoding credentials.\u003C\u002Fstrong> SMTP passwords and API keys committed to a repo are a security incident waiting to happen. Use environment variables or a vault.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Treating \"accepted\" as \"delivered.\"\u003C\u002Fstrong> A successful SMTP send or \u003Ccode>2xx\u003C\u002Fcode> API response means the message was \u003Cem>accepted for delivery\u003C\u002Fem>, not that it reached the inbox. Process bounces and complaints.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring bounces and complaints.\u003C\u002Fstrong> Repeatedly mailing invalid addresses or users who marked you as spam destroys your reputation. Honor suppression lists.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mixing transactional and marketing mail on the same domain\u002FIP.\u003C\u002Fstrong> A spammy campaign can poison the reputation that delivers your password resets. Separate streams (and ideally subdomains).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Using port 25 from a cloud server.\u003C\u002Fstrong> It's blocked by most providers. Use 587 (STARTTLS) or 465 for SMTP, or just use the API over 443.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No retry or idempotency strategy.\u003C\u002Fstrong> Network blips happen. Retry failed sends, but use idempotency keys (a feature APIs provide) so a retry doesn't double-send a receipt.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending from a no-reply address with no monitoring.\u003C\u002Fstrong> You'll miss replies and bounce notifications. At minimum, route delivery events to logging or alerting.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>SMTP vs API: Decision Checklist\u003C\u002Fh2>\n\u003Cp>Use this quick checklist to settle the \u003Cstrong>smtp vs api\u003C\u002Fstrong> question for your project:\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Choose an email API if you:\u003C\u002Fstrong>\u003Cbr \u002F>\n- Are building a new application or service from scratch\u003Cbr \u002F>\n- Send transactional email (OTPs, receipts, alerts, notifications)\u003Cbr \u002F>\n- Need delivery webhooks and per-message tracking\u003Cbr \u002F>\n- Deploy to serverless or locked-down cloud environments\u003Cbr \u002F>\n- Want built-in templates, suppression, and idempotency\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Choose SMTP if you:\u003C\u002Fstrong>\u003Cbr \u002F>\n- Integrate third-party software that only supports SMTP\u003Cbr \u002F>\n- Maintain a legacy app where rewriting isn't worth it\u003Cbr \u002F>\n- Require config-only provider portability\u003Cbr \u002F>\n- Want one relay endpoint for many heterogeneous systems\u003C\u002Fp>\n\u003Cp>Many teams use \u003Cstrong>both\u003C\u002Fstrong>: the HTTP API from their own application code, and the SMTP relay for off-the-shelf tools — pointed at the same provider so deliverability, suppression, and analytics stay unified.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>Is an email API faster than SMTP?\u003C\u002Fh3>\n\u003Cp>In most real-world scenarios, yes. An email API uses a single stateless HTTPS request, while SMTP requires a connection handshake and a multi-step dialogue per send. Under high or bursty load, the API model scales with less tuning because the provider manages connection pooling and fan-out. At very low volume, the difference is negligible.\u003C\u002Fp>\n\u003Ch3>Does SMTP or API deliver better to the inbox?\u003C\u002Fh3>\n\u003Cp>Neither inherently. Both ultimately deliver via SMTP to the recipient's mail server, and inbox placement depends on SPF, DKIM, DMARC, and sender reputation — not the transport you use. What an API adds is better \u003Cem>visibility\u003C\u002Fem> into deliverability through webhooks and analytics, helping you diagnose and fix issues faster.\u003C\u002Fp>\n\u003Ch3>What is a transactional email API?\u003C\u002Fh3>\n\u003Cp>A transactional email API is an HTTP interface built specifically for app-triggered, one-to-one messages such as password resets, receipts, OTP codes, and notifications. It's optimized for low latency, high throughput, and detailed per-message delivery tracking, as opposed to bulk marketing campaigns.\u003C\u002Fp>\n\u003Ch3>Can I use both SMTP and an email API with the same provider?\u003C\u002Fh3>\n\u003Cp>Yes, and many teams do. You can call the HTTP API from your own application code while pointing third-party tools (CMS, monitoring, CRM) that only speak SMTP at the same provider's relay. This keeps deliverability, suppression lists, and analytics unified across both channels.\u003C\u002Fp>\n\u003Ch3>Which is more secure, SMTP or an email API?\u003C\u002Fh3>\n\u003Cp>An email API is generally easier to secure. It uses scoped, individually rotatable API keys over HTTPS, whereas SMTP relies on long-lived username\u002Fpassword credentials that are often shared across systems. With an API you can scope permissions to send-only, audit usage per key, and revoke a single leaked key without disrupting other integrations.\u003C\u002Fp>\n\u003Ch3>Do I still need SPF, DKIM, and DMARC if I use an email API?\u003C\u002Fh3>\n\u003Cp>Absolutely. Domain authentication is required regardless of whether you send via SMTP or an API. Following Google and Yahoo's 2024 sender requirements, properly configured SPF, DKIM, and DMARC are effectively mandatory for inbox placement. A good provider helps you set these up and signs messages on your behalf.\u003C\u002Fp>\n\u003Ch3>Should I run my own SMTP server instead of using a provider?\u003C\u002Fh3>\n\u003Cp>For nearly all SaaS teams, no. Self-hosting a mail server means managing IP reputation, IP warm-up, blocklist removal, bounce processing, and feedback loops — a continuous operational burden. A managed provider handles this infrastructure, and you can still use SMTP (as a relay) or the API on top of it.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>The \u003Cstrong>smtp vs api\u003C\u002Fstrong> decision comes down to what you're optimizing for. SMTP wins on universal compatibility and portability — it's the right choice for legacy systems and third-party software that speaks nothing else. A modern \u003Cstrong>email API\u003C\u002Fstrong> wins on integration speed, latency, security, observability, and scale, which is why it's the better default for new, application-driven transactional email.\u003C\u002Fp>\n\u003Cp>For most SaaS products in 2026, the practical recommendation is clear: \u003Cstrong>use a transactional email API for your own application code\u003C\u002Fstrong>, and lean on a managed provider's SMTP relay only where off-the-shelf tools require it. Either way, the deliverability that matters — SPF, DKIM, DMARC, reputation, and suppression — comes from your provider's infrastructure, not the transport method. Choose a provider that gets those fundamentals right, then pick the interface that fits each system.\u003C\u002Fp>\n\u003Ch2>Send Your First Email in Minutes with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> gives developers both options on one platform: a fast, observable \u003Cstrong>transactional email API\u003C\u002Fstrong> over HTTPS \u003Cem>and\u003C\u002Fem> a managed SMTP relay — so you can integrate your own code via API and point third-party tools at SMTP, all with unified deliverability, webhooks, suppression, and analytics. SPF, DKIM, and DMARC are handled for you, and delivery events stream to your endpoints in real time.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can fund your account and start sending without a corporate card, lengthy billing setup, or currency friction — ideal for global teams and crypto-native startups.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Get your API key and send your first email in minutes →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","c2213cd5-3c2a-4a4b-91a8-4edc114de399","2026-06-24T18:28:18.000504+03:00","2026-06-24T18:28:18.000519+03:00","SMTP vs Email API: Which Should Developers Choose?","SMTP vs API for sending email: compare speed, deliverability, security, and scale. A developer-focused guide to choosing the right method for your app.","2026-06-26T09:00:00+03:00",[228,229,35],"smtp vs api","email api",{"id":231,"body":232,"uuid":233,"created_at":234,"updated_at":235,"brand":13,"header":236,"short_body":237,"image":5,"published":17,"published_at":238,"tags":239},6,"\u003Cp>Transactional email is the automated, one-to-one message your application sends to a single recipient in response to a specific action — a password reset, an order confirmation, a verification code, or an invoice. If you run a SaaS product, transactional email is not a marketing channel; it is core product infrastructure. When it breaks, users can't sign up, can't log in, and can't recover their accounts.\u003C\u002Fp>\n\u003Cp>This guide explains what transactional email is, how it differs from marketing email, how delivery actually works under the hood, and how to choose a transactional email service or transactional email API that won't let you down at 2 a.m. It's written for SaaS founders, engineers, and CTOs who need a practical, technical understanding rather than vendor marketing.\u003C\u002Fp>\n\u003Cp>By the end, you'll know how to architect, send, and monitor transactional email so it lands in the inbox reliably.\u003C\u002Fp>\n\u003Ch2>What Is Transactional Email? (Definition)\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Transactional email is a message triggered automatically by a user's action or a system event, sent to one recipient, that contains information the recipient expects and needs.\u003C\u002Fstrong> Because it's expected and individually relevant, it has the highest engagement of any email category and is exempt from most marketing-consent rules (though anti-spam laws still apply).\u003C\u002Fp>\n\u003Cp>Common examples include:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Account lifecycle:\u003C\u002Fstrong> signup verification, email confirmation, welcome messages\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authentication:\u003C\u002Fstrong> password resets, magic links, one-time passcodes (OTP), 2FA codes\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Commerce:\u003C\u002Fstrong> order confirmations, receipts, invoices, shipping notifications\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Notifications:\u003C\u002Fstrong> comment replies, mentions, security alerts, usage limit warnings\u003C\u002Fli>\n\u003Cli>\u003Cstrong>System events:\u003C\u002Fstrong> failed payment notices, subscription renewals, export-ready alerts\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>The defining trait is the \u003Cstrong>1:1 trigger relationship\u003C\u002Fstrong>: one user action produces one email to one person. That's different from a campaign blasted to a list of thousands.\u003C\u002Fp>\n\u003Ch3>Transactional vs. Marketing Email\u003C\u002Fh3>\n\u003Cp>The two categories look similar but have different goals, infrastructure needs, and legal treatment. Mixing them on the same sending infrastructure is one of the most common mistakes that destroys deliverability.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Attribute\u003C\u002Fth>\n\u003Cth>Transactional Email\u003C\u002Fth>\n\u003Cth>Marketing Email\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Trigger\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>User action \u002F system event\u003C\u002Ftd>\n\u003Ctd>Scheduled campaign\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Recipients\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>One person at a time\u003C\u002Ftd>\n\u003Ctd>Bulk list\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Consent model\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Expected; implied by action\u003C\u002Ftd>\n\u003Ctd>Explicit opt-in required\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Examples\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Password reset, receipt\u003C\u002Ftd>\n\u003Ctd>Newsletter, promo blast\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Priority\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Time-critical, must arrive in seconds\u003C\u002Ftd>\n\u003Ctd>Can tolerate minutes\u002Fhours\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Engagement\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Very high open rates (often 80–90%+)\u003C\u002Ftd>\n\u003Ctd>Lower (typically 15–25%)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Unsubscribe\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Usually not required for true transactional\u003C\u002Ftd>\n\u003Ctd>Legally required\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Sending pattern\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Steady, event-driven\u003C\u002Ftd>\n\u003Ctd>Spiky, batch\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>A key implication: \u003Cstrong>separate your sending streams.\u003C\u002Fstrong> Send transactional and marketing mail from different subdomains (and ideally different IPs or providers) so a bad marketing campaign can't tank the reputation of the domain your password resets depend on.\u003C\u002Fp>\n\u003Ch2>How Transactional Email Works\u003C\u002Fh2>\n\u003Cp>Understanding the delivery path helps you debug problems and choose the right provider. Here's the lifecycle of a single transactional email from your app to the user's inbox.\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Trigger\u003C\u002Fstrong> — A user submits a signup form. Your backend event fires.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>API call or SMTP handoff\u003C\u002Fstrong> — Your application hands the message to a transactional email service via an HTTPS API call or an SMTP relay.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Queuing\u003C\u002Fstrong> — The provider accepts and queues the message, returning a message ID for tracking.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authentication signing\u003C\u002Fstrong> — The provider signs the message with DKIM and sends from an IP\u002Fdomain aligned with your SPF and DMARC records.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>MTA delivery\u003C\u002Fstrong> — The provider's Mail Transfer Agent (MTA) opens an SMTP connection to the recipient's mail server (Gmail, Outlook, etc.).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Receiving-server evaluation\u003C\u002Fstrong> — The inbox provider checks authentication, sender reputation, content, and engagement history to decide: inbox, spam, or reject.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Webhook events\u003C\u002Fstrong> — Delivery, open, click, bounce, and complaint events are sent back to your app via webhooks so you can react (e.g., suspend a hard-bouncing address).\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>The two ways your app talks to a provider:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Transactional email API (HTTP):\u003C\u002Fstrong> A REST call (usually JSON) — fast, returns rich responses, easy to add metadata and tags. This is the recommended path for modern apps.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>SMTP relay:\u003C\u002Fstrong> Your app connects over SMTP like a traditional mail client. Useful for legacy frameworks or tools that already speak SMTP, but slower and harder to instrument.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Why You Shouldn't Send Directly From Your Server\u003C\u002Fh3>\n\u003Cp>It's tempting to install Postfix or call \u003Ccode>sendmail\u003C\u002Fcode> and skip a provider. Don't. Sending transactional email yourself means you're now responsible for:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>IP reputation and warmup\u003C\u002Fstrong> — A fresh server IP has zero reputation; mailbox providers throttle or junk you until you slowly build trust.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authentication infrastructure\u003C\u002Fstrong> — Correctly configured SPF, DKIM, and DMARC, with key rotation.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Feedback loops and blocklist monitoring\u003C\u002Fstrong> — Detecting when Gmail, Microsoft, or Spamhaus flags you.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retries, queuing, and rate limiting\u003C\u002Fstrong> — Per-mailbox-provider throttling rules.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bounce and complaint handling\u003C\u002Fstrong> — Parsing SMTP\u002FDSN responses and suppressing bad addresses.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A dedicated transactional email service absorbs all of this. Your engineering time is better spent on your product.\u003C\u002Fp>\n\u003Ch2>Why Deliverability Is the Whole Game\u003C\u002Fh2>\n\u003Cp>For transactional email, \u003Cstrong>deliverability\u003C\u002Fstrong> — whether the message actually reaches the inbox — is everything. A receipt in the spam folder is functionally a lost email. A delayed OTP is a failed login. Industry data consistently shows that roughly \u003Cstrong>1 in 6 legitimate emails never reaches the inbox\u003C\u002Fstrong>, landing in spam or going missing. For transactional mail, that failure rate is unacceptable.\u003C\u002Fp>\n\u003Cp>Three pillars determine deliverability:\u003C\u002Fp>\n\u003Ch3>1. Authentication: SPF, DKIM, and DMARC\u003C\u002Fh3>\n\u003Cp>These DNS-based standards prove you're authorized to send from your domain. Since 2024, Gmail and Yahoo \u003Cstrong>require\u003C\u002Fstrong> all senders to authenticate, and bulk senders must publish a DMARC policy. Misconfigured authentication is the single most common reason transactional email lands in spam.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF (Sender Policy Framework):\u003C\u002Fstrong> A DNS TXT record listing servers allowed to send for your domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM (DomainKeys Identified Mail):\u003C\u002Fstrong> A cryptographic signature that proves the message wasn't altered and came from your domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC (Domain-based Message Authentication):\u003C\u002Fstrong> Tells receivers what to do when SPF\u002FDKIM fail and where to send reports. Start with \u003Ccode>p=none\u003C\u002Fcode> to monitor, then move to \u003Ccode>quarantine\u003C\u002Fcode> and \u003Ccode>reject\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Example DNS records:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-dns\">; SPF\nexample.com.  TXT  &quot;v=spf1 include:_spf.postwing.app ~all&quot;\n\n; DMARC\n_dmarc.example.com.  TXT  &quot;v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; adkim=s; aspf=s&quot;\n\n; DKIM (provided by your transactional email service)\npw1._domainkey.example.com.  CNAME  pw1.dkim.postwing.app.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>A good transactional email service generates these records for you and verifies them in its dashboard. Authentication should be a copy-paste step, not a research project.\u003C\u002Fp>\n\u003Ch3>2. Sender Reputation\u003C\u002Fh3>\n\u003Cp>Mailbox providers score the IP and domain you send from based on history: bounce rates, spam complaints, engagement, and consistency. Key practices:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Use a dedicated sending subdomain\u003C\u002Fstrong> (e.g., \u003Ccode>mail.example.com\u003C\u002Fcode> or \u003Ccode>notifications.example.com\u003C\u002Fcode>) so reputation accrues to you, not a shared pool you don't control.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep bounce rates low\u003C\u002Fstrong> (under ~2%) by validating addresses and suppressing hard bounces immediately.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep complaint rates very low\u003C\u002Fstrong> (under ~0.1%). Even transactional mail can draw complaints if it's unwanted or confusing.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>3. Content and Engagement\u003C\u002Fh3>\n\u003Cp>Even authenticated mail from a clean IP can be junked if the content looks spammy. Keep transactional templates clean: minimal links, no misleading subject lines, a clear sender name, and a real reply-to address. Avoid URL shorteners and link-heavy footers in OTP or password-reset emails.\u003C\u002Fp>\n\u003Ch2>How to Send Transactional Email: A Code Example\u003C\u002Fh2>\n\u003Cp>Here's what sending a transactional email through a modern transactional email API looks like. The pattern is the same across providers: authenticate, build the payload, POST it, handle the response.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import requests\n\ndef send_welcome_email(api_key: str, to_email: str, name: str) -&gt; str:\n    response = requests.post(\n        &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend&quot;,\n        headers={\n            &quot;Authorization&quot;: f&quot;Bearer {api_key}&quot;,\n            &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n        },\n        json={\n            &quot;from&quot;: &quot;Acme &lt;welcome@notifications.acme.com&gt;&quot;,\n            &quot;to&quot;: to_email,\n            &quot;subject&quot;: &quot;Welcome to Acme — confirm your email&quot;,\n            &quot;html&quot;: f&quot;&lt;p&gt;Hi {name}, please confirm your account.&lt;\u002Fp&gt;&quot;,\n            &quot;text&quot;: f&quot;Hi {name}, please confirm your account.&quot;,\n            &quot;tags&quot;: [&quot;onboarding&quot;, &quot;welcome&quot;],\n        },\n        timeout=10,\n    )\n    response.raise_for_status()\n    return response.json()[&quot;message_id&quot;]\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The same call in Node.js:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">async function sendWelcomeEmail(apiKey, toEmail, name) {\n  const res = await fetch(&quot;https:\u002F\u002Fapi.postwing.app\u002Fv1\u002Fsend&quot;, {\n    method: &quot;POST&quot;,\n    headers: {\n      Authorization: `Bearer ${apiKey}`,\n      &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n    },\n    body: JSON.stringify({\n      from: &quot;Acme &lt;welcome@notifications.acme.com&gt;&quot;,\n      to: toEmail,\n      subject: &quot;Welcome to Acme — confirm your email&quot;,\n      html: `&lt;p&gt;Hi ${name}, please confirm your account.&lt;\u002Fp&gt;`,\n      text: `Hi ${name}, please confirm your account.`,\n      tags: [&quot;onboarding&quot;, &quot;welcome&quot;],\n    }),\n  });\n  if (!res.ok) throw new Error(`Send failed: ${res.status}`);\n  const { message_id } = await res.json();\n  return message_id;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Always Send Asynchronously\u003C\u002Fh3>\n\u003Cp>Never block a user-facing request on email delivery. If your provider has a slow response or transient outage, your signup flow shouldn't hang. Enqueue the send to a background worker:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\"># In the request handler — fast, non-blocking\ndef signup(user):\n    user.save()\n    email_queue.enqueue(send_welcome_email, api_key, user.email, user.name)\n    return {&quot;status&quot;: &quot;ok&quot;}  # responds immediately\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This decoupling also gives you a natural place for retries and rate limiting.\u003C\u002Fp>\n\u003Ch3>Handle Webhooks for Bounces and Complaints\u003C\u002Fh3>\n\u003Cp>To keep your sender reputation healthy, consume the provider's event webhooks and act on them:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">@app.post(&quot;\u002Fwebhooks\u002Femail&quot;)\ndef email_webhook(event):\n    if event[&quot;type&quot;] in (&quot;hard_bounce&quot;, &quot;complaint&quot;):\n        # Stop sending to this address to protect reputation\n        suppress_address(event[&quot;recipient&quot;])\n    elif event[&quot;type&quot;] == &quot;delivered&quot;:\n        mark_delivered(event[&quot;message_id&quot;])\n    return {&quot;ok&quot;: True}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Ignoring hard bounces and complaints is the fastest way to wreck deliverability.\u003C\u002Fp>\n\u003Ch2>Choosing a Transactional Email Service\u003C\u002Fh2>\n\u003Cp>When evaluating a transactional email service or transactional email API, weigh these criteria:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Criterion\u003C\u002Fth>\n\u003Cth>What to look for\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Deliverability\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Authenticated sending, dedicated\u002Fsegmented IPs, strong inbox placement\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>API quality\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Clean REST API, idempotency keys, SDKs, clear error responses\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Webhooks\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Delivery, bounce, complaint, open, click events with retries\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Speed\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Low latency, sub-second acceptance, fast queue processing\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Analytics &amp; logs\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Searchable per-message logs, suppression lists, event history\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Pricing\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Predictable per-email cost, no surprise overage cliffs\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Payment flexibility\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Card, invoice — or, for global teams, crypto\u002FUSDC\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Compliance\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>SPF\u002FDKIM\u002FDMARC setup, GDPR, data residency options\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Support\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Responsive technical support when delivery breaks\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>Pricing Models\u003C\u002Fh3>\n\u003Cp>Most transactional email services charge per email sent, often with a free tier and volume discounts. Watch for:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Hidden costs:\u003C\u002Fstrong> dedicated IPs, extended log retention, and premium support are often add-ons.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Overage pricing:\u003C\u002Fstrong> what happens when you exceed your plan mid-month? Some providers degrade or block sending.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Payment friction:\u003C\u002Fstrong> international teams, indie developers, and crypto-native startups increasingly want to pay without a US credit card. Platforms like \u003Cstrong>Postwing\u003C\u002Fstrong> accept \u003Cstrong>USDC on Base\u003C\u002Fstrong>, so you can fund your account on-chain and start sending without card-processing friction or currency conversion overhead.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A rough sense of scale: most providers land somewhere around \u003Cstrong>$0.50–$1.00 per thousand emails\u003C\u002Fstrong> at moderate volume, with the first thousands often free. Always model your real volume — a price that's cheap at 10k\u002Fmonth can be expensive at 10M\u002Fmonth.\u003C\u002Fp>\n\u003Ch2>Common Mistakes With Transactional Email\u003C\u002Fh2>\n\u003Cp>Avoid these well-known failure modes that catch even experienced teams.\u003C\u002Fp>\n\u003Col>\n\u003Cli>\n\u003Cp>\u003Cstrong>Mixing marketing and transactional streams.\u003C\u002Fstrong> A promotional blast that triggers complaints poisons the reputation of the domain your password resets rely on. Use separate subdomains and ideally separate providers or IP pools.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Skipping or misconfiguring SPF\u002FDKIM\u002FDMARC.\u003C\u002Fstrong> This is the top cause of inbox failure. Verify alignment, not just presence — \u003Ccode>aspf\u003C\u002Fcode> and \u003Ccode>adkim\u003C\u002Fcode> matter.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Sending synchronously in the request path.\u003C\u002Fstrong> A provider hiccup shouldn't break signup. Always enqueue to a background worker.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Ignoring bounces and complaints.\u003C\u002Fstrong> Continuing to mail a hard-bouncing address tells mailbox providers you don't maintain your list, even for transactional mail. Auto-suppress immediately.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>No plain-text part.\u003C\u002Fstrong> HTML-only emails look spammier and break for some clients. Always include a \u003Ccode>text\u003C\u002Fcode> alternative.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Putting marketing in transactional emails.\u003C\u002Fstrong> Cramming promos into a receipt can legally reclassify it as marketing (triggering unsubscribe requirements) and increases complaints. Keep transactional mail strictly informational.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>No monitoring or alerting.\u003C\u002Fstrong> If your verification emails silently stop delivering, you want to know in minutes, not when users churn. Track delivery rate and alert on drops.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Reusing one sender address for everything.\u003C\u002Fstrong> Use purpose-specific senders (\u003Ccode>receipts@\u003C\u002Fcode>, \u003Ccode>security@\u003C\u002Fcode>, \u003Ccode>no-reply@\u003C\u002Fcode>) so reputation and filtering stay granular.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Not testing across mailbox providers.\u003C\u002Fstrong> Gmail, Outlook, Apple Mail, and Yahoo filter differently. Test inbox placement on each before launch.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>Forgetting idempotency.\u003C\u002Fstrong> A retried send without an idempotency key can double-email users. Use the provider's idempotency support or your own dedup logic.\u003C\u002Fp>\n\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>Transactional Email Best Practices Checklist\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>[ ] Send from a dedicated subdomain (e.g., \u003Ccode>notifications.yourdomain.com\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>[ ] Configure and verify SPF, DKIM, and DMARC (move DMARC toward \u003Ccode>p=reject\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>[ ] Separate transactional and marketing sending streams\u003C\u002Fli>\n\u003Cli>[ ] Send asynchronously via a queue, never in the request path\u003C\u002Fli>\n\u003Cli>[ ] Include both HTML and plain-text parts\u003C\u002Fli>\n\u003Cli>[ ] Consume webhooks and auto-suppress hard bounces and complaints\u003C\u002Fli>\n\u003Cli>[ ] Use idempotency keys to prevent duplicate sends\u003C\u002Fli>\n\u003Cli>[ ] Monitor delivery rate and alert on anomalies\u003C\u002Fli>\n\u003Cli>[ ] Keep transactional content strictly informational\u003C\u002Fli>\n\u003Cli>[ ] Test inbox placement across major mailbox providers\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>FAQ: Transactional Email\u003C\u002Fh2>\n\u003Ch3>What is a transactional email, in simple terms?\u003C\u002Fh3>\n\u003Cp>A transactional email is an automated message sent to one person because they did something — like resetting a password, placing an order, or signing up. It delivers information the recipient is expecting, which is why it has very high open rates and is treated differently from marketing email.\u003C\u002Fp>\n\u003Ch3>What's the difference between transactional and marketing email?\u003C\u002Fh3>\n\u003Cp>Transactional email is triggered by a user action and sent 1:1; the recipient expects it and consent is implied by the action. Marketing email is sent in bulk to a list, requires explicit opt-in, and must include an unsubscribe link. They should be sent from separate infrastructure to protect deliverability.\u003C\u002Fp>\n\u003Ch3>Do I need a transactional email service, or can I send it myself?\u003C\u002Fh3>\n\u003Cp>You technically can self-host, but you'd have to manage IP warmup, authentication, blocklist monitoring, retries, and bounce handling. A dedicated transactional email service handles all of that and delivers far better inbox placement, which is why nearly all SaaS companies use one.\u003C\u002Fp>\n\u003Ch3>Why are my transactional emails going to spam?\u003C\u002Fh3>\n\u003Cp>The most common cause is missing or misconfigured SPF, DKIM, or DMARC authentication. Other causes include a poor sender reputation, sending from a shared domain used for marketing blasts, spammy content, high bounce rates, and not suppressing complaints. Fix authentication first.\u003C\u002Fp>\n\u003Ch3>What is a transactional email API?\u003C\u002Fh3>\n\u003Cp>A transactional email API is an HTTP (usually REST\u002FJSON) endpoint you call from your application to send an email programmatically. You POST the recipient, subject, and body, and the service handles authentication, delivery, retries, and event tracking — returning a message ID and webhook events.\u003C\u002Fp>\n\u003Ch3>How fast should transactional emails be delivered?\u003C\u002Fh3>\n\u003Cp>Time-sensitive messages like OTPs and password resets should arrive within seconds. A good transactional email service accepts the message in well under a second and delivers to major providers almost immediately. Always send asynchronously so provider latency never blocks your app.\u003C\u002Fp>\n\u003Ch3>Can I pay for a transactional email service with crypto?\u003C\u002Fh3>\n\u003Cp>Yes — some modern providers support crypto payments. Postwing, for example, accepts USDC on Base, letting global teams, indie developers, and crypto-native startups fund their account on-chain without needing a credit card or dealing with currency conversion.\u003C\u002Fp>\n\u003Ch3>How much does transactional email cost?\u003C\u002Fh3>\n\u003Cp>Pricing is typically per email, often free for the first few thousand and roughly $0.50–$1.00 per thousand at moderate volume. Costs scale with volume, so model your real sending pattern. Watch for add-on fees for dedicated IPs, log retention, and overages.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Transactional email is product infrastructure, not a marketing add-on. It's how users verify accounts, recover passwords, and confirm purchases — so when it fails, your product fails with it. The fundamentals are straightforward but unforgiving: authenticate properly with SPF, DKIM, and DMARC; separate transactional from marketing streams; send asynchronously; and act on bounces and complaints to protect your sender reputation.\u003C\u002Fp>\n\u003Cp>Choosing the right transactional email service — one with strong deliverability, a clean transactional email API, reliable webhooks, and transparent pricing — lets you focus on building your product instead of debugging mail servers. Get the foundation right, monitor it, and your most critical emails will land in the inbox every time.\u003C\u002Fp>\n\u003Ch2>Start Sending With Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email delivery platform built for developers and SaaS companies. You get a clean transactional email API, guided SPF\u002FDKIM\u002FDMARC setup, real-time webhooks for bounces and complaints, searchable per-message logs, and inbox-grade deliverability out of the box — plus the flexibility to pay with \u003Cstrong>USDC on Base\u003C\u002Fstrong>, with no credit card required.\u003C\u002Fp>\n\u003Cp>Spin up an account, drop in your DNS records, and send your first authenticated transactional email in minutes. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Get started with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","ca5aeb7d-64b6-4a90-8ba3-e9066d7f7bc0","2026-06-24T18:28:17.988807+03:00","2026-06-24T18:28:17.988846+03:00","What Is Transactional Email? Complete Guide for SaaS Founders","Transactional email explained for SaaS founders: how it works, why deliverability matters, key APIs, costs, and best practices to ship reliable emails.","2026-06-24T09:00:00+03:00",[216,240,35],"transactional email service"]