When you POST a message to AhaSend's API and get back a 202 Accepted, you've learned exactly one thing: the message was queued. That's the honest answer at that moment, delivery hasn't happened yet. What you don't know is everything that matters next. Did it reach the mailbox? Did the address bounce because it never existed? Did it land, then bounce three days later when the mailbox filled up?
Polling for that is a dead end. You'd be hammering an API on a guess, and you still couldn't react in real time. Webhooks flip it around: AhaSend POSTs to you the instant something happens, so your application can mark a dead address as invalid, stop a doomed retry loop, or fire a Slack alert, automatically, the moment the event occurs.
This guide builds a bounce handler you can ship, verified, idempotent, and reacting only to the events that actually require action.
TL;DR: Create a webhook in the AhaSend dashboard pointing at an HTTPS endpoint. On every request, verify the Standard Webhooks signature with your webhook secret, return a
2xxfast, and process asynchronously. React tomessage.bouncedandmessage.failed(delivery is dead) andsuppression.created(the address is now blocked) by updating your own records. Use thewebhook-idheader to dedupe retries. Test the whole path with sandbox mode before a real bounce ever happens. Full working code below.
Why bounces demand a reaction, not a log line
It's tempting to treat bounces as something to glance at in a dashboard once a week. They aren't. A bounce is the mail system telling you an address is bad, and ignoring it is actively expensive in two ways.
First, reputation. Mailbox providers watch how often you send to addresses that don't exist. Keep hammering dead addresses and they read it as the behavior of a spammer working a purchased list, and they start treating all your mail with suspicion, including the password resets and receipts that real users are waiting for. Every avoidable bounce chips at the reputation that lands your good mail in the inbox.
Second, wasted work and wrong conclusions. An app that doesn't know an address is dead will keep queuing mail to it, keep showing "email sent" in the UI, and keep letting a user sit locked out because their reset link is bouncing into the void. Handling bounces automatically means your system's picture of reality stays accurate without anyone watching a dashboard.
AhaSend already does the hard part for you, it suppresses addresses that hard-bounce so you can't accidentally re-send to them (more on that below). Webhooks are how you mirror that intelligence into your own database and act on it.
The bounce lifecycle, so the events make sense
Before wiring anything up, it helps to know which events fire and when. AhaSend tracks the full lifecycle of every message, and the webhooks guide lays out the paths. The ones relevant to bounce handling:
A hard bounce is an immediate, permanent rejection, the address doesn't exist, or the domain doesn't resolve:
Reception → Bounced → Suppression CreatedA soft bounce is a temporary problem, a full mailbox, a server hiccup. AhaSend doesn't give up on the first try; it retries up to 7 times over 24 hours. If the mailbox recovers, the message delivers and you never needed to care. If it never recovers, the message fails permanently and the address is suppressed:
Reception → Deferred → Deferred → ... → Failed → Suppression CreatedThe takeaway for your code: deferrals are noise, failures are signal. You don't react to a deferral event (AhaSend names it message.transient_error), AhaSend is still working on it. You react when a message reaches message.bounced or message.failed, and above all when a suppression.created event tells you the address is now blocked from future sends. That last event is the cleanest possible trigger, because it carries the recipient, the reason, and an expiry date, and it fires regardless of which bounce path got the address there.
Step 1: Create the webhook
In the AhaSend dashboard, open Webhooks, click Add Webhook, and enter your endpoint URL. It must be HTTPS in production (plain HTTP is allowed for local development). Choose to receive all events, or select just the ones you'll act on, for bounce handling, message.bounced, message.failed, and suppression.created are enough.
When you save, AhaSend shows you a webhook secret. Copy it now and store it as an environment variable, it's what proves an incoming request actually came from AhaSend, and you can't display it again later.
# .env
AHASEND_WEBHOOK_SECRET=your-webhook-secretStep 2: Verify every request before you trust it
Your webhook URL is, by necessity, a public endpoint. Anyone who finds it can POST whatever they like, a forged suppression.created for a competitor's most valuable customer, say. So the first thing your handler does, before reading a single field, is verify the signature.
AhaSend follows the Standard Webhooks specification, so you don't hand-roll the crypto. Every request carries three headers:
webhook-id: msg_2abc...
webhook-timestamp: 1718000000
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=The webhook-signature is an HMAC of the raw body and timestamp, keyed with your secret. The official standardwebhooks library checks it for you, including rejecting requests whose timestamp is too old, which shuts down replay attacks. Install it (pip install standardwebhooks) and the verification is a few lines:
# webhook_security.py
import os
from standardwebhooks.webhooks import Webhook
webhook = Webhook(os.environ["AHASEND_WEBHOOK_SECRET"])
def verify(raw_body: bytes, headers: dict) -> dict:
"""Raises on a bad signature; returns the parsed event on success."""
return webhook.verify(raw_body, {
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": headers["webhook-signature"],
})One detail that breaks more integrations than any other: verify against the raw request body, byte for byte. If your framework parses JSON and you re-serialize it before verifying, the bytes change, the HMAC won't match, and every legitimate webhook fails. Grab the body before anything touches it.
Step 3: Respond fast, process later
AhaSend expects a 2xx within 10 seconds, and it means it: anything slower counts as a failure. Failed deliveries are retried 6 times over roughly 16 minutes, and an endpoint that racks up 100 consecutive failures gets disabled automatically (you'll get an email when that happens). So your handler must not do real work on the request thread, no sending follow-up emails, no slow third-party calls inline. Acknowledge immediately, then process in the background.
Here's the full endpoint in FastAPI, which pairs naturally with the async sending patterns covered in our guide to sending email from FastAPI:
# main.py
from fastapi import BackgroundTasks, FastAPI, Request, Response
from webhook_security import verify
from handlers import process_event
app = FastAPI()
@app.post("/webhooks/ahasend")
async def ahasend_webhook(request: Request, background_tasks: BackgroundTasks):
raw_body = await request.body() # raw bytes, before any parsing
try:
event = verify(raw_body, request.headers)
except Exception:
# Bad signature, do not retry, do not process.
return Response(status_code=400)
# Acknowledge now; do the work after the response is sent.
webhook_id = request.headers["webhook-id"]
background_tasks.add_task(process_event, webhook_id, event)
return Response(status_code=200)Note the two return paths. A bad signature returns 400, a permanent "no", so AhaSend won't retry a forgery. A verified event returns 200 straight away and hands the actual work to BackgroundTasks, so the response goes out well inside the 10-second budget no matter how slow your downstream work is.
Step 4: Make it idempotent, then act
Because AhaSend retries on any non-2xx (and networks occasionally deliver the same request twice even after you answered), your processing has to be safe to run more than once for the same event. The webhook-id header is purpose-built for this: it's unique per event, stable across retries. Record the ones you've handled and skip duplicates.
Every AhaSend webhook shares the same envelope, a type, a timestamp, and a data object, so you switch on type and act:
# handlers.py
_processed_ids: set[str] = set() # use a real store (Redis/DB) in production
def process_event(webhook_id: str, event: dict) -> None:
if webhook_id in _processed_ids:
return # already handled this exact event, a retry
_processed_ids.add(webhook_id)
event_type = event["type"]
data = event["data"]
if event_type == "suppression.created":
# The decisive event: this address is now blocked from sending.
deactivate_email(
address=data["recipient"],
reason=data.get("reason", "suppressed"),
until=data.get("expires_at"),
)
elif event_type in ("message.bounced", "message.failed"):
# Delivery is permanently dead for this message.
flag_delivery_problem(
address=data["recipient"],
message_id=data.get("id"),
event_type=event_type,
)
def deactivate_email(address: str, reason: str, until: str | None) -> None:
# Your logic: mark the user's email unverified, pause sends, alert the team.
...
def flag_delivery_problem(address: str, message_id: str | None, event_type: str) -> None:
# Your logic: record the failure, surface it in the UI, decide on next steps.
...The shape of data mirrors the documented event payloads, message events carry fields like recipient, subject, and the AhaSend message id; the suppression.created payload carries recipient, reason, and expires_at. The exact schema for every event type is in the webhook events API reference.
Why center the logic on suppression.created rather than the bounce events themselves? Because it's the one that maps directly to "stop sending here." Once an address is suppressed, AhaSend will refuse to deliver to it anyway, so mirroring that state into your own database keeps the two systems honest: your app stops queuing mail that AhaSend would only reject.
Step 5: Suppressions expire, and you can reconcile
Webhooks can be missed, a deploy at the wrong moment, an endpoint that was down past the retry window, so it's worth knowing the safety net. Start with a useful surprise: suppressions aren't permanent. AhaSend auto-expires them after 30 days, on the theory that a mailbox that was full last month might be fine today. That's why the expires_at field is worth storing: a "this address is paused until March 14" is very different from "this address is dead forever," and your UI can say so.
If you ever need to rebuild your view of the suppression list, after downtime, say, or to backfill, don't rely on having caught every webhook. Pull the current state from the list suppressions endpoint:
GET https://api.ahasend.com/v2/accounts/{account_id}/[email protected]
Authorization: Bearer aha-sk-your-64-character-keyA key scoped to suppressions:read is all this needs. Webhooks are your real-time signal; the API is your source of truth for reconciliation. Using both means a missed webhook is an inconvenience, not a data-integrity bug.
Step 6: Test the whole path before a real bounce
You shouldn't have to wait for a genuine bounce to know your handler works. AhaSend gives you two ways to exercise it.
From the dashboard, your webhook's detail page has a Send Test Event button that fires realistic sample payloads at your endpoint, perfect for confirming signature verification and your type switch in seconds. For local development, expose your machine with a tunnel like ngrok and point the webhook there.
To test the full send-to-bounce loop, use sandbox mode. Add "sandbox": true and a simulated result to your send call and AhaSend runs the message through the real pipeline, including firing the matching webhooks, without delivering anything or costing you anything:
payload["sandbox"] = True
payload["sandbox_result"] = "bounce" # drives the bounce + suppression eventsNow you can assert, in an automated test, that a bounce produces exactly the suppression record and the deactivated-email state you expect. The sandbox mode guide covers the available results.
What you end up with
A single endpoint that verifies every request, answers in milliseconds, dedupes retries, and turns three event types into accurate state in your own database, all of it testable without sending a real email. For maybe sixty lines of code, your application stops guessing about delivery and starts knowing.
And the pipeline you just built is the foundation for everything else AhaSend can tell you. The same verified, idempotent endpoint handles message.opened and message.clicked for engagement, or message.delivered to show real delivery confirmation in your UI, you add a branch to the type switch and nothing else changes. Bounces are just the event worth wiring up first.