<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Omni Genz]]></title><description><![CDATA[Omni Genz]]></description><link>https://omnigenz.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 01:29:48 GMT</lastBuildDate><atom:link href="https://omnigenz.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Track bounces and spam complaints with Brevo webhooks and a small Flask app]]></title><description><![CDATA[A code-first companion to the Brevo webhooks setup guide on Omni Genz, where it was first published. Sending a transactional email is only half the job…]]></description><link>https://omnigenz.hashnode.dev/track-bounces-and-spam-complaints-with-brevo-webhooks-and-a-small-flask-app</link><guid isPermaLink="true">https://omnigenz.hashnode.dev/track-bounces-and-spam-complaints-with-brevo-webhooks-and-a-small-flask-app</guid><category><![CDATA[Flask Framework]]></category><category><![CDATA[email]]></category><category><![CDATA[webhooks]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Omni Genz]]></dc:creator><pubDate>Fri, 30 Oct 2026 09:26:44 GMT</pubDate><content:encoded><![CDATA[<p><em>A code-first companion to the <a href="https://omnigenz.site/brevo-webhooks/">Brevo webhooks setup guide on Omni Genz</a>, where it was first published.</em></p>
<p>Sending a transactional email is only half the job. You also want to know when it bounced, got blocked, or was marked as spam, so you can stop emailing that address and protect your sender reputation. Polling an API for that is wasteful. Brevo (formerly Sendinblue) can push each event to your server as it happens, using a webhook.</p>
<p>In this post we'll build a small Flask receiver, protect it with a bearer token, register it with the Brevo API, and act on the events that matter.</p>
<h2>What Brevo sends</h2>
<p>For transactional email, Brevo POSTs a JSON body to your URL for each event you subscribe to. The fields you'll use most:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><code>event</code></td>
<td>e.g. <code>delivered</code>, <code>hard_bounce</code>, <code>soft_bounce</code>, <code>blocked</code>, <code>spam</code>, <code>invalid_email</code>, <code>unsubscribed</code>, <code>opened</code>, <code>click</code>, <code>deferred</code></td>
</tr>
<tr>
<td><code>email</code></td>
<td>the recipient</td>
</tr>
<tr>
<td><code>message-id</code></td>
<td>matches the <code>messageId</code> you got when you sent the email</td>
</tr>
<tr>
<td><code>ts_event</code></td>
<td>when it happened (Unix seconds, UTC)</td>
</tr>
<tr>
<td><code>reason</code></td>
<td>why it bounced or was deferred</td>
</tr>
<tr>
<td><code>X-Mailin-custom</code></td>
<td>your own value, if you set that header when sending</td>
</tr>
<tr>
<td><code>tags</code></td>
<td>tags you added to the email</td>
</tr>
</tbody></table>
<p>Note the hyphen in <code>message-id</code> and the underscores in event names like <code>hard_bounce</code>. They don't match the camelCase names (<code>hardBounce</code>) you use when <em>creating</em> the webhook, which trips a lot of people up.</p>
<h2>1. The receiver</h2>
<pre><code class="language-bash">pip install flask requests
</code></pre>
<pre><code class="language-python"># app.py
import hmac
import os
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_TOKEN = os.environ["BREVO_WEBHOOK_TOKEN"]  # a long random string you choose

SUPPRESS = {"hard_bounce", "invalid_email", "blocked", "spam", "unsubscribed"}


def authorized(req) -&gt; bool:
    header = req.headers.get("Authorization", "")
    expected = f"Bearer {WEBHOOK_TOKEN}"
    return hmac.compare_digest(header, expected)


def handle_event(evt: dict) -&gt; None:
    kind = evt.get("event")
    email = evt.get("email")
    msg_id = evt.get("message-id")

    if kind in SUPPRESS:
        # Stop emailing this address. Replace with your own DB call.
        print(f"suppress {email}: {kind} ({evt.get('reason', '')})")
    elif kind == "soft_bounce":
        # Temporary problem (full inbox, server down). Count it, don't suppress yet.
        print(f"soft bounce for {email}: {evt.get('reason', '')}")
    elif kind == "delivered":
        print(f"delivered {msg_id} to {email}")
    else:
        print(f"{kind} for {email}")


@app.post("/webhooks/brevo")
def brevo_webhook():
    if not authorized(request):
        abort(401)

    payload = request.get_json(silent=True)
    if payload is None:
        abort(400)

    # One event per request normally; a list if you enable batching
    events = payload if isinstance(payload, list) else [payload]
    for evt in events:
        handle_event(evt)

    return "", 200
</code></pre>
<p>A few design choices here:</p>
<ul>
<li><strong>Answer fast.</strong> The route does a little work and returns <code>200</code>. If your real handling is slow (database writes, CRM updates), push events onto a queue and process them in the background.</li>
<li><strong>Constant-time compare.</strong> <code>hmac.compare_digest</code> avoids leaking the token through timing differences.</li>
<li><strong>Handle both shapes.</strong> Brevo can batch events (more on that below), so the code accepts a single object or a list.</li>
<li><strong>Be idempotent.</strong> Treat the same event arriving twice as harmless, e.g. suppressing an address that's already suppressed does nothing.</li>
</ul>
<p>Run it locally:</p>
<pre><code class="language-bash">export BREVO_WEBHOOK_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
flask --app app run --port 5000
</code></pre>
<p>Brevo needs a public HTTPS URL, so for testing expose the port with a tunnel tool of your choice and note the URL it gives you.</p>
<h2>2. Test it before Brevo does</h2>
<p>Fake an event with curl to check your auth and parsing:</p>
<pre><code class="language-bash">curl -i -X POST http://localhost:5000/webhooks/brevo \
  -H "Authorization: Bearer $BREVO_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event":"hard_bounce","email":"bad@example.com","message-id":"&lt;test@local&gt;","reason":"mailbox does not exist","ts_event":1767225600}'
</code></pre>
<p>You should see <code>200</code> and a "suppress" line in the Flask log. Try it without the header and you should get <code>401</code>.</p>
<h2>3. Register the webhook with the API</h2>
<p>You can create webhooks in the Brevo dashboard, but doing it in code keeps it reproducible across environments. The endpoint is <code>POST https://api.brevo.com/v3/webhooks</code>:</p>
<pre><code class="language-python"># create_webhook.py
import os
import requests

resp = requests.post(
    "https://api.brevo.com/v3/webhooks",
    headers={
        "api-key": os.environ["BREVO_API_KEY"],
        "accept": "application/json",
        "content-type": "application/json",
    },
    json={
        "url": "https://your-public-url.example.com/webhooks/brevo",
        "description": "Bounces and complaints -&gt; suppression list",
        "type": "transactional",
        "events": [
            "delivered", "hardBounce", "softBounce", "blocked",
            "spam", "invalid", "unsubscribed",
        ],
        "auth": {"type": "bearer", "token": os.environ["BREVO_WEBHOOK_TOKEN"]},
    },
    timeout=15,
)
print(resp.status_code, resp.json())  # 201 {"id": 1234567}
</code></pre>
<p>A <code>201</code> with an <code>id</code> means it's live. Save that ID so you can update or delete the webhook later. Only subscribe to the events you'll act on: adding <code>opened</code> and <code>click</code> on a busy account means a lot of extra traffic.</p>
<h2>4. Securing it properly</h2>
<p>The bearer token above is one of several options Brevo supports. You can also:</p>
<ul>
<li>put basic-auth credentials in the URL (<code>https://user:pass@host/path</code>),</li>
<li>send custom headers with each call (the <code>headers</code> field on the create request), or</li>
<li>allowlist Brevo's published IP ranges at your firewall.</li>
</ul>
<p>Pick at least one. An open webhook URL lets anyone mark your customers as bounced.</p>
<h2>5. High volume? Turn on batching</h2>
<p>Add <code>"batched": true</code> when creating the webhook and Brevo groups events into windows of about five minutes, with up to 500 events per call. You'll get far fewer requests, at the cost of a short delay. That's why the receiver already loops over a list.</p>
<h2>6. Use the custom header to link events to your data</h2>
<p>When you send an email through the API, add a header such as <code>"X-Mailin-custom": "user:42|order:A-1042"</code>. Brevo returns that value in the webhook's <code>X-Mailin-custom</code> field, so you can update the right record without looking up the message ID.</p>
<h2>Wrapping up</h2>
<p>With about 60 lines of Python, hard bounces and spam complaints stop silently piling up. Your list stays clean and your sender reputation stays healthy. Field names and event lists can change, so keep Brevo's developer docs open when you extend this.</p>
<p>The full guide, with dashboard setup steps and a testing walkthrough, is here: <a href="https://omnigenz.site/brevo-webhooks/">Brevo Webhooks: Simple Setup Guide</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Send transactional email from Python through the Brevo SMTP relay]]></title><description><![CDATA[A code-first companion to the Brevo SMTP relay setup guide on Omni Genz. Password resets, order receipts and contact-form alerts are the emails users…]]></description><link>https://omnigenz.hashnode.dev/send-transactional-email-from-python-through-the-brevo-smtp-relay</link><guid isPermaLink="true">https://omnigenz.hashnode.dev/send-transactional-email-from-python-through-the-brevo-smtp-relay</guid><category><![CDATA[Python]]></category><category><![CDATA[email]]></category><category><![CDATA[smtp]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Omni Genz]]></dc:creator><pubDate>Thu, 29 Oct 2026 09:26:24 GMT</pubDate><content:encoded><![CDATA[<p><em>A code-first companion to the <a href="https://omnigenz.site/brevo-smtp-relay/">Brevo SMTP relay setup guide on Omni Genz</a>.</em></p>
<p>Password resets, order receipts and contact-form alerts are the emails users notice most when they go missing. Sending them straight from your server often lands them in spam. Routing them through an SMTP relay, a server built only for sending mail, usually fixes that.</p>
<p>Brevo (formerly Sendinblue) includes SMTP on every plan, including the free one, which Brevo's pricing page lists at up to 300 emails a day. Here's a minimal Python setup using only the standard library.</p>
<h2>The settings you need</h2>
<p>From <strong>Settings &gt; SMTP &amp; API &gt; SMTP</strong> in Brevo:</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Host</td>
<td><code>smtp-relay.brevo.com</code></td>
</tr>
<tr>
<td>Port</td>
<td><code>587</code> with STARTTLS (or <code>2525</code> if 587 is blocked, <code>465</code> for SSL)</td>
</tr>
<tr>
<td>Username</td>
<td>Your SMTP login, copied from that page</td>
</tr>
<tr>
<td>Password</td>
<td>An SMTP key (not your account password, not an API key)</td>
</tr>
</tbody></table>
<p>Store the login and key as environment variables, never in code:</p>
<pre><code class="language-bash">export BREVO_SMTP_LOGIN="your-smtp-login"
export BREVO_SMTP_KEY="your-smtp-key"
export MAIL_FROM="Your App &lt;hello@yourdomain.com&gt;"
</code></pre>
<h2>Sending with smtplib</h2>
<pre><code class="language-python">import os
import smtplib
from email.message import EmailMessage

def send_email(to: str, subject: str, text: str, html: str | None = None) -&gt; None:
    msg = EmailMessage()
    msg["From"] = os.environ["MAIL_FROM"]
    msg["To"] = to
    msg["Subject"] = subject
    msg.set_content(text)
    if html:
        msg.add_alternative(html, subtype="html")

    with smtplib.SMTP("smtp-relay.brevo.com", 587, timeout=20) as server:
        server.starttls()
        server.login(os.environ["BREVO_SMTP_LOGIN"], os.environ["BREVO_SMTP_KEY"])
        server.send_message(msg)

if __name__ == "__main__":
    send_email(
        "you@example.com",
        "Reset your password",
        "Use this link to reset your password: https://example.com/reset/123",
        '&lt;p&gt;Use this link to reset your password:&lt;/p&gt;&lt;p&gt;&lt;a href="https://example.com/reset/123"&gt;Reset password&lt;/a&gt;&lt;/p&gt;',
    )
</code></pre>
<p>On port 465, swap <code>smtplib.SMTP(...)</code> plus <code>starttls()</code> for <code>smtplib.SMTP_SSL("smtp-relay.brevo.com", 465)</code>.</p>
<h2>When login fails (535 Authentication failed)</h2>
<ul>
<li>The username is the SMTP login from the SMTP page, not <code>smtp-relay.brevo.com</code>.</li>
<li>The password must be an SMTP key. Your account password and API keys won't work here.</li>
<li>Re-paste the key if a space or line break slipped in.</li>
<li>Don't mix SSL with port 587 or STARTTLS with 465.</li>
<li>Brevo switches off keys that haven't sent a good email in 90 days, so an old key may simply be disabled. Generate a new one.</li>
</ul>
<h2>Before production</h2>
<p>Authenticate your sending domain in Brevo (SPF, DKIM and DMARC), send from an address on that domain, and check <strong>Transactional &gt; Logs</strong> after your first test to confirm delivery.</p>
<p>The full walkthrough, including how to generate the SMTP key and choose a port, is on Omni Genz: <a href="https://omnigenz.site/brevo-smtp-relay/">What is an SMTP relay? Brevo SMTP relay setup guide</a>.</p>
]]></content:encoded></item></channel></rss>