AhaSend
Terug naar Blog

Zo verwerk je e-mail bounces automatisch met AhaSend webhooks

Mark Kraakman
Mark Kraakman
Inzichten

Wanneer je een bericht naar de API van AhaSend POST en een 202 Accepted terugkrijgt, weet je precies één ding: het bericht staat in de wachtrij. Dat is het eerlijke antwoord op dat moment, de aflevering heeft nog niet plaatsgevonden. Wat je niet weet, is alles wat daarna telt. Is het in de mailbox aangekomen? Bounce het adres omdat het nooit heeft bestaan? Is het eerst afgeleverd en drie dagen later alsnog gebounced omdat de mailbox vol raakte?

Daarvoor pollen is een doodlopende weg. Je zou een API bestoken op basis van een gok, en je kunt alsnog niet realtime reageren. Webhooks draaien het om: AhaSend POST naar jou zodra er iets gebeurt, zodat je applicatie een dood adres als ongeldig kan markeren, een kansloze retry-loop kan stoppen of een Slack-melding kan afvuren, automatisch, op het moment dat het event plaatsvindt.

In deze gids bouwen we een bounce-handler die je direct kunt gebruiken: geverifieerd, idempotent, en alleen reagerend op de events die echt om actie vragen.

Samengevat: maak in het AhaSend-dashboard een webhook aan die naar een HTTPS-endpoint wijst. Verifieer bij elk request de Standard Webhooks-signature met je webhook secret, geef snel een 2xx terug en verwerk asynchroon. Reageer op message.bounced en message.failed (aflevering is definitief mislukt) en op suppression.created (het adres is nu geblokkeerd) door je eigen administratie bij te werken. Gebruik de webhook-id header om retries te dedupliceren. Test het hele pad met sandbox mode voordat er ooit een echte bounce plaatsvindt. Volledige werkende code hieronder.

Waarom bounces om een reactie vragen, niet om een logregel

Het is verleidelijk om bounces te zien als iets dat je eens per week in een dashboard bekijkt. Dat zijn ze niet. Een bounce is het mailsysteem dat je vertelt dat een adres niet deugt, en dat negeren kost je op twee manieren geld.

Ten eerste: reputatie. Mailboxproviders letten erop hoe vaak je naar niet-bestaande adressen verstuurt. Blijf je op dode adressen hameren, dan lezen zij dat als het gedrag van een spammer met een gekochte lijst, en gaan ze al je mail met argwaan behandelen, inclusief de wachtwoordresets en bevestigingen waar echte gebruikers op wachten. Elke vermijdbare bounce knaagt aan de reputatie die je goede mail in de inbox laat belanden.

Ten tweede: verspild werk en verkeerde conclusies. Een app die niet weet dat een adres dood is, blijft er mail voor inplannen, blijft "e-mail verzonden" tonen in de interface, en laat een gebruiker buitengesloten zitten omdat zijn resetlink het niets in bounced. Bounces automatisch verwerken betekent dat het beeld dat je systeem van de werkelijkheid heeft klopt, zonder dat iemand een dashboard in de gaten houdt.

AhaSend doet het zware werk al voor je: adressen die hard bouncen worden gesupprimeerd zodat je er niet per ongeluk opnieuw naar kunt versturen (daarover hieronder meer). Webhooks zijn de manier om die kennis naar je eigen database te spiegelen en ernaar te handelen.

De bounce-levenscyclus, zodat de events logisch worden

Voordat je iets aansluit, helpt het om te weten welke events wanneer afgaan. AhaSend volgt de volledige levenscyclus van elk bericht, en de webhooks-gids beschrijft de paden. De relevante voor bounce-afhandeling:

Een hard bounce is een onmiddellijke, permanente afwijzing: het adres bestaat niet, of het domein resolvet niet:

Reception → Bounced → Suppression Created

Een soft bounce is een tijdelijk probleem: een volle mailbox, een haperende server. AhaSend geeft niet op na de eerste poging; het probeert het tot 7 keer opnieuw binnen 24 uur. Herstelt de mailbox, dan wordt het bericht afgeleverd en had je er nooit naar om hoeven kijken. Herstelt hij niet, dan mislukt het bericht definitief en wordt het adres gesupprimeerd:

Reception → Deferred → Deferred → ... → Failed → Suppression Created

De les voor je code: deferrals zijn ruis, failures zijn signaal. Op een deferral-event (AhaSend noemt het message.transient_error) reageer je niet, AhaSend is er nog mee bezig. Je reageert wanneer een bericht message.bounced of message.failed bereikt, en vooral wanneer een suppression.created event je vertelt dat het adres nu geblokkeerd is voor toekomstige verzendingen. Dat laatste event is de schoonst denkbare trigger, want het bevat de ontvanger, de reden en een vervaldatum, en het vuurt ongeacht welk bounce-pad het adres daar bracht.

Stap 1: maak de webhook aan

Open in het AhaSend-dashboard Webhooks, klik op Add Webhook en vul de URL van je endpoint in. In productie moet dat HTTPS zijn (voor lokale ontwikkeling is gewoon HTTP toegestaan). Kies ervoor om alle events te ontvangen, of selecteer alleen de events waar je iets mee doet; voor bounce-afhandeling zijn message.bounced, message.failed en suppression.created genoeg.

Bij het opslaan toont AhaSend een webhook secret. Kopieer die meteen en bewaar hem als environment-variabele: dit is het bewijs dat een binnenkomend request echt van AhaSend komt, en je kunt hem later niet nog eens opvragen.

# .env
AHASEND_WEBHOOK_SECRET=jouw-webhook-secret

Stap 2: verifieer elk request voordat je het vertrouwt

Je webhook-URL is noodgedwongen een publiek endpoint. Iedereen die hem vindt kan er van alles naartoe POSTen, bijvoorbeeld een vervalste suppression.created voor de belangrijkste klant van een concurrent. Het eerste dat je handler dus doet, nog voordat er ook maar één veld wordt gelezen, is de signature verifiëren.

AhaSend volgt de Standard Webhooks-specificatie, dus je hoeft de cryptografie niet zelf te schrijven. Elk request draagt drie headers:

webhook-id: msg_2abc...
webhook-timestamp: 1718000000
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

De webhook-signature is een HMAC van de raw body en de timestamp, versleuteld met je secret. De officiële standardwebhooks library controleert dit voor je, inclusief het afwijzen van requests met een te oude timestamp, wat replay-aanvallen de pas afsnijdt. Installeer hem (pip install standardwebhooks) en de verificatie is een paar regels:

# 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:
    """Gooit een exception bij een foute signature; geeft het geparsede event terug bij succes."""
    return webhook.verify(raw_body, {
        "webhook-id": headers["webhook-id"],
        "webhook-timestamp": headers["webhook-timestamp"],
        "webhook-signature": headers["webhook-signature"],
    })

Eén detail dat meer integraties breekt dan wat ook: verifieer tegen de raw request body, byte voor byte. Als je framework de JSON parst en je die opnieuw serialiseert vóór de verificatie, veranderen de bytes, matcht de HMAC niet, en faalt elke legitieme webhook. Pak de body voordat iets anders eraan zit.

Stap 3: antwoord snel, verwerk later

AhaSend verwacht een 2xx binnen 10 seconden, en dat is serieus: alles wat trager is telt als mislukking. Mislukte afleveringen worden 6 keer opnieuw geprobeerd binnen ruwweg 16 minuten, en een endpoint dat 100 opeenvolgende mislukkingen verzamelt wordt automatisch uitgeschakeld (je krijgt dan een e-mail). Je handler mag dus geen echt werk doen op de request-thread: geen vervolgmails versturen, geen trage calls naar derden inline. Bevestig onmiddellijk, verwerk daarna op de achtergrond.

Hier is het volledige endpoint in FastAPI, dat vanzelfsprekend aansluit op de asynchrone verzendpatronen uit onze gids over e-mail versturen vanuit 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, vóór enige parsing

    try:
        event = verify(raw_body, request.headers)
    except Exception:
        # Foute signature: niet opnieuw proberen, niet verwerken.
        return Response(status_code=400)

    # Bevestig nu; doe het werk nadat de response is verstuurd.
    webhook_id = request.headers["webhook-id"]
    background_tasks.add_task(process_event, webhook_id, event)
    return Response(status_code=200)

Let op de twee return-paden. Een foute signature geeft 400 terug, een definitief "nee", zodat AhaSend een vervalsing niet opnieuw aanbiedt. Een geverifieerd event geeft direct 200 terug en geeft het echte werk door aan BackgroundTasks, zodat de response ruim binnen het budget van 10 seconden de deur uit is, hoe traag je downstream-werk ook is.

Stap 4: maak het idempotent, en handel dan

Omdat AhaSend opnieuw probeert bij elke non-2xx (en netwerken zo nu en dan hetzelfde request twee keer afleveren, zelfs nadat je hebt geantwoord), moet je verwerking veilig meerdere keren kunnen draaien voor hetzelfde event. De webhook-id header is daar precies voor gemaakt: uniek per event, stabiel over retries heen. Registreer welke je hebt afgehandeld en sla duplicaten over.

Elke AhaSend-webhook deelt dezelfde envelop, een type, een timestamp en een data object, dus je switcht op type en handelt:

# handlers.py
_processed_ids: set[str] = set()  # gebruik in productie een echte store (Redis/DB)

def process_event(webhook_id: str, event: dict) -> None:
    if webhook_id in _processed_ids:
        return  # dit exacte event is al afgehandeld, een retry
    _processed_ids.add(webhook_id)

    event_type = event["type"]
    data = event["data"]

    if event_type == "suppression.created":
        # Het beslissende event: dit adres is nu geblokkeerd voor verzending.
        deactivate_email(
            address=data["recipient"],
            reason=data.get("reason", "suppressed"),
            until=data.get("expires_at"),
        )

    elif event_type in ("message.bounced", "message.failed"):
        # Aflevering is voor dit bericht definitief mislukt.
        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:
    # Jouw logica: markeer het e-mailadres als ongeverifieerd, pauzeer verzendingen, waarschuw het team.
    ...

def flag_delivery_problem(address: str, message_id: str | None, event_type: str) -> None:
    # Jouw logica: registreer de mislukking, toon het in de interface, bepaal vervolgstappen.
    ...

De vorm van data volgt de gedocumenteerde event-payloads: message-events bevatten velden als recipient, subject en het AhaSend message id; de payload van suppression.created bevat recipient, reason en expires_at. Het exacte schema van elk event-type staat in de API-referentie voor webhook-events.

Waarom draait de logica om suppression.created in plaats van om de bounce-events zelf? Omdat dat het event is dat rechtstreeks vertaalt naar "stop met versturen naar dit adres." Zodra een adres gesupprimeerd is, weigert AhaSend er sowieso aan af te leveren, dus door die status naar je eigen database te spiegelen houden de twee systemen elkaar eerlijk: je app stopt met het inplannen van mail die AhaSend toch zou weigeren.

Stap 5: suppressies verlopen, en je kunt reconciliëren

Webhooks kunnen gemist worden, een deploy op het verkeerde moment, een endpoint dat langer plat lag dan het retry-venster, dus het is goed om het vangnet te kennen. Om te beginnen een nuttige verrassing: suppressies zijn niet permanent. AhaSend laat ze na 30 dagen automatisch verlopen, vanuit de gedachte dat een mailbox die vorige maand vol zat, vandaag misschien weer werkt. Daarom is het expires_at veld het bewaren waard: "dit adres is gepauzeerd tot 14 maart" is iets heel anders dan "dit adres is voorgoed dood", en je interface kan dat dan ook zo brengen.

Moet je ooit je beeld van de suppressielijst opnieuw opbouwen, na downtime bijvoorbeeld, of om bij te werken, vertrouw er dan niet op dat je elke webhook hebt gevangen. Haal de actuele status op via het list suppressions endpoint:

GET https://api.ahasend.com/v2/accounts/{account_id}/[email protected]
Authorization: Bearer aha-sk-jouw-64-tekens-sleutel

Een key met alleen de scope suppressions:read is hiervoor genoeg. Webhooks zijn je realtime signaal; de API is je bron van waarheid voor reconciliatie. Gebruik je beide, dan is een gemiste webhook een ongemak, geen data-integriteitsbug.

Stap 6: test het hele pad vóór de eerste echte bounce

Je zou niet op een echte bounce hoeven te wachten om te weten dat je handler werkt. AhaSend geeft je twee manieren om hem te oefenen.

Vanuit het dashboard heeft de detailpagina van je webhook een Send Test Event knop die realistische voorbeeldpayloads op je endpoint afvuurt, perfect om binnen enkele seconden je signature-verificatie en je type-switch te bevestigen. Voor lokale ontwikkeling ontsluit je je machine met een tunnel als ngrok en richt je de webhook daarop.

Om de volledige lus van verzenden tot bounce te testen gebruik je sandbox mode. Voeg "sandbox": true en een gesimuleerd resultaat toe aan je verzendcall en AhaSend haalt het bericht door de echte pijplijn, inclusief het afvuren van de bijbehorende webhooks, zonder iets af te leveren en zonder dat het je iets kost:

payload["sandbox"] = True
payload["sandbox_result"] = "bounce"   # triggert de bounce- en suppressie-events

Nu kun je in een geautomatiseerde test vaststellen dat een bounce precies het suppressierecord en de gedeactiveerde-e-mailstatus oplevert die je verwacht. De sandbox mode gids beschrijft de beschikbare resultaten.

Wat je uiteindelijk hebt

Eén endpoint dat elk request verifieert, in milliseconden antwoordt, retries dedupliceert en drie event-types omzet in een kloppende status in je eigen database, en dat allemaal testbaar zonder één echte e-mail te versturen. Voor pakweg zestig regels code stopt je applicatie met gissen over aflevering en begint ze het te weten.

En de pijplijn die je zojuist hebt gebouwd is het fundament voor al het andere dat AhaSend je kan vertellen. Hetzelfde geverifieerde, idempotente endpoint verwerkt message.opened en message.clicked voor betrokkenheid, of message.delivered om echte afleverbevestiging in je interface te tonen; je voegt een branch toe aan de type-switch en verder verandert er niets. Bounces zijn simpelweg het event dat je als eerste wilt aansluiten.

Begin gratis met AhaSend →