Systemvoraussetzungen
Wer Bounces und Beschwerden automatisiert verarbeiten will, braucht neben einem laufenden Resend-Konto vor allem einen öffentlich erreichbaren Endpunkt, der Ereignisse entgegennimmt und ihre Herkunft prüft. Resend liefert diese Ereignisse als Webhook: ein automatischer HTTP-POST-Aufruf an eine selbst betriebene URL, ausgelöst durch ein Ereignis auf Seiten des Anbieters, statt dass die eigene Anwendung ständig nachfragt, ob sich etwas geändert hat. Die folgende Tabelle listet die Software- und Infrastrukturbausteine, die ein Route Handler mit Signaturprüfung und eine Sperrliste dafür brauchen.
| Komponente | Anforderung |
|---|---|
| Next.js | Version 16.2 oder neuer, App Router mit Route Handlers |
| Node.js | Version 20.9 LTS oder neuer als Laufzeitumgebung |
| npm-Paket resend | Version 6.x (aktuell 6.22.1), das offizielle JavaScript-SDK von Resend |
| npm-Paket svix | Version 1.99.x, prüft die Signatur jedes eingehenden Webhook-Events |
| Datenbank | PostgreSQL 14 oder neuer für die Sperrlisten-Tabelle; jede relationale Datenbank mit eindeutigem Index funktioniert ebenso |
| Resend-Konto | Verifizierte Absenderdomain mit aktivem SPF- und DKIM-Eintrag im DNS |
| Webhook-Endpunkt | Öffentlich erreichbare HTTPS-Adresse, zum Beispiel https://kundenseite.de/api/webhooks/resend |
SPF und DKIM sind zwei Einträge im Domain Name System, die einem empfangenden Mailserver bestätigen, dass eine Nachricht wirklich von der angegebenen Domain stammt und auf dem Transportweg nicht verändert wurde. Fehlen beide Einträge, stuft ein Großteil der Mailserver eingehende Post schon aus diesem Grund als verdächtig ein, lange bevor ein einzelner Bounce oder eine Beschwerde überhaupt zur Debatte steht.
Systemarchitektur
Jede E-Mail, die eine Kundenseite über die Resend-API verschickt, durchläuft danach mehrere Stationen, bis der Empfänger sie liest oder eben nicht liest. Resend schreibt in seiner Nutzungsrichtlinie eine Beschwerdequote von unter 0,08 Prozent und eine Bounce-Rate von unter 4 Prozent vor. Wer diese Werte überschreitet, riskiert laut Anbieter eine Kontosperrung ohne Vorwarnung. Weil ein einzelnes Resend-Konto meist eine ganze Sendedomain für mehrere Kundenseiten gleichzeitig bedient, trifft eine solche Sperrung nicht nur die eine Adresse, die zuletzt bounced ist, sondern von diesem Moment an jede Nachricht jeder angeschlossenen Seite.
flowchart TD
A[Kundenseite ruft resend.emails.send auf] --> B[Resend nimmt den Versand an]
B --> C[Empfaenger-Mailserver verarbeitet die Nachricht]
C --> D[Zustellung erfolgreich]
C --> E[Adresse existiert nicht mehr]
C --> F[Nutzer meldet die Nachricht als Spam]
D --> G[Resend feuert email.delivered]
E --> H[Resend feuert email.bounced]
F --> I[Resend feuert email.complained]
G --> J[Svix signiert das Ereignis]
H --> J
I --> J
J --> K[POST an den Route Handler der Kundenseite]
K --> L[Route Handler prueft svix-id, svix-timestamp, svix-signature]
L --> M[Eintrag in der Sperrlisten-Tabelle je nach Ereignistyp]
M --> N[Naechster Sendeaufruf fragt die Sperrliste zuerst ab]
N --> O[Adresse gesperrt, Versand wird uebersprungen]
N --> P[Adresse frei, Resend versendet die Mail]
Resend erzeugt die Signatur für diese Ereignisse nicht über eine eigene Lösung, sondern reicht die Zustellung an Svix weiter, einen spezialisierten Anbieter für Webhook-Infrastruktur. Svix signiert jede Nutzlast mit einem geheimen Schlüssel, der beim Anlegen des Webhook-Endpunkts im Resend-Dashboard einmalig ausgegeben wird und mit dem Präfix whsec_ beginnt. Drei HTTP-Header begleiten jede Zustellung: svix-id als eindeutige Kennung des jeweiligen Zustellversuchs, svix-timestamp als Zeitpunkt des Versands und svix-signature als kryptografische Prüfsumme über Kennung, Zeitstempel und Rohtext der Nachricht zusammen.
Ein Endpunkt, der diese Prüfsumme nicht nachrechnet, kann nicht unterscheiden, ob eine Anfrage tatsächlich von Resend stammt oder ob jemand von außen einen beliebigen POST-Aufruf mit dem Ereignistyp email.delivered an dieselbe URL schickt. Genau darin liegt der praktische Schaden. Ein einzelner curl-Befehl gegen die öffentliche Endpunkt-Adresse reicht ohne Prüfung aus, um eine tatsächlich gesperrte Adresse fälschlich als zugestellt zu markieren, oder umgekehrt, um eine völlig intakte Adresse ungerechtfertigt in die Sperrliste zu schreiben und damit echte Empfänger von künftigen Mails auszuschließen.
Für die Sperrliste zählt vor allem der Unterschied zwischen zwei Ausfallarten. Ein harter Bounce bedeutet, dass der Mailserver des Empfängers die Adresse dauerhaft zurückweist, etwa weil sie nie existiert hat oder das Postfach dauerhaft geschlossen wurde. Resend meldet das ausschließlich über das Ereignis email.bounced, dessen verschachteltes bounce-Objekt im Feld type den Wert Permanent trägt. Ein weicher Bounce ist dagegen ein vorübergehendes Problem, ein volles Postfach oder ein kurzzeitig überlasteter Mailserver zum Beispiel. Dafür löst Resend das eigene Ereignis email.delivery_delayed aus und versucht die Zustellung zunächst selbstständig erneut, bevor überhaupt ein Mensch eingreifen müsste. Wer beide Fälle gleich behandelt und schon nach dem ersten weichen Bounce sperrt, verliert Empfänger, deren Postfach eine Stunde später wieder Platz hat.
Schritt-für-Schritt-Umsetzung
Die Umsetzung gliedert sich in vier Teile: den Endpunkt im Resend-Dashboard registrieren, eine Tabelle für die Sperrliste anlegen, den Route Handler mit Signaturprüfung schreiben und jeden Sendeaufruf gegen diese Tabelle prüfen.
1. Webhook-Endpunkt im Resend-Dashboard anlegen
Im Resend-Dashboard führt der Menüpunkt Webhooks zu einer Übersicht aller registrierten Endpunkte. Ein Klick auf Add Webhook öffnet ein Formular für die Ziel-URL, in diesem Beispiel https://kundenseite.de/api/webhooks/resend. Direkt darunter lässt sich auswählen, welche Ereignistypen an diesen Endpunkt gehen sollen. Für die Sperrliste reichen email.bounced, email.complained und email.delivery_delayed, ergänzt um email.delivered, wenn zusätzlich ein Übersichts-Dashboard je Kundenseite entstehen soll. Nach dem Speichern zeigt die Detailseite des Endpunkts den Signing Secret im Format whsec_ gefolgt von einer zufälligen Zeichenfolge. Dieser Wert wandert unverändert in die Umgebungsvariable RESEND_WEBHOOK_SECRET jeder Umgebung, die den Route Handler ausführt, lokal, im Staging und in Produktion jeweils getrennt.
2. Sperrlisten-Tabelle in der Datenbank anlegen
Die Tabelle braucht mindestens die E-Mail-Adresse als eindeutigen Schlüssel, den Grund der Sperrung und einen Zähler für aufeinanderfolgende weiche Bounces. Eine zweite, kleine Tabelle hält zusätzlich schon verarbeitete svix-id-Werte fest, damit ein wiederholt zugestellter Webhook nicht zweimal denselben Effekt auslöst.
model Suppression {
id String @id @default(cuid())
email String @unique
reason String
softBounceCount Int @default(0)
lastEventAt DateTime @default(now())
createdAt DateTime @default(now())
}
model ProcessedWebhookEvent {
svixId String @id
processedAt DateTime @default(now())
}
3. Route Handler mit Signaturprüfung schreiben
Der Route Handler liegt unter app/api/webhooks/resend/route.ts und exportiert eine POST-Funktion. Entscheidend ist die Reihenfolge der Schritte darin: Der Rohtext des Requests wird gelesen, bevor irgendetwas geparst wird, denn die Signatur bezieht sich genau auf diesen Bytestring. Jede Zwischenstufe, die den Body erst in ein JavaScript-Objekt verwandelt und danach wieder in Text zurückschreibt, verändert Leerzeichen oder Feldreihenfolge und lässt die Prüfung scheitern, obwohl das Ereignis echt war.
// app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Webhook } from "svix";
import { prisma } from "@/lib/prisma";
const SOFT_BOUNCE_LIMIT = 3;
export async function POST(req: NextRequest) {
const payload = await req.text();
const svixHeaders = {
"svix-id": req.headers.get("svix-id") || "",
"svix-timestamp": req.headers.get("svix-timestamp") || "",
"svix-signature": req.headers.get("svix-signature") || "",
};
let event;
try {
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET || "");
event = wh.verify(payload, svixHeaders);
} catch (error) {
console.error("Resend-Webhook: Signatur ungueltig", error);
return new NextResponse("Invalid signature", { status: 400 });
}
const svixId = svixHeaders["svix-id"];
const alreadyProcessed = await prisma.processedWebhookEvent.findUnique({
where: { svixId },
});
if (alreadyProcessed) {
return NextResponse.json({ received: true, duplicate: true });
}
await prisma.processedWebhookEvent.create({ data: { svixId } });
const address = event.data.to[0];
if (event.type === "email.bounced") {
await prisma.suppression.upsert({
where: { email: address },
create: { email: address, reason: "hard_bounce" },
update: { reason: "hard_bounce", lastEventAt: new Date() },
});
}
if (event.type === "email.complained") {
await prisma.suppression.upsert({
where: { email: address },
create: { email: address, reason: "complaint" },
update: { reason: "complaint", lastEventAt: new Date() },
});
}
if (event.type === "email.delivery_delayed") {
const existing = await prisma.suppression.findUnique({ where: { email: address } });
const previousCount = existing ? existing.softBounceCount : 0;
const count = previousCount + 1;
const reachedLimit = count >= SOFT_BOUNCE_LIMIT;
const reason = reachedLimit ? "soft_bounce" : "soft_bounce_watch";
await prisma.suppression.upsert({
where: { email: address },
create: { email: address, reason, softBounceCount: count },
update: { reason, softBounceCount: count, lastEventAt: new Date() },
});
}
return NextResponse.json({ received: true });
}
4. Sperrliste vor jedem Versand abfragen
Der eigentliche Schutz entsteht erst, wenn jeder Versandaufruf zuerst in die Tabelle schaut. Eine schmale Wrapper-Funktion um resend.emails.send genügt dafür in den meisten Codebasen, weil ohnehin jede ausgehende Mail durch genau diese eine Stelle läuft.
// lib/send-mail.ts
import { Resend } from "resend";
import { prisma } from "@/lib/prisma";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendTransactionalMail(to: string, subject: string, html: string) {
const address = to.toLowerCase();
const suppressed = await prisma.suppression.findUnique({ where: { email: address } });
if (suppressed && suppressed.reason !== "soft_bounce_watch") {
console.warn(`Versand uebersprungen: ${address} (${suppressed.reason})`);
return { skipped: true, reason: suppressed.reason };
}
return resend.emails.send({
from: "Kundenseite <no-reply@kundenseite.de>",
to: address,
subject,
html,
});
}
Häufige Fehlerquellen
- Der Route Handler ruft req.json() auf, bevor die Signatur geprüft wird, und verändert damit den Rohtext des Requests. Jede Verifizierung schlägt danach fehl, auch bei echten Ereignissen von Resend. Die Lösung besteht darin, den Body ausschließlich über req.text() zu lesen und diesen unveränderten String an svix zu übergeben; das JSON-Parsing folgt erst danach, auf dem bereits verifizierten Objekt.
- Als Webhook-Secret landet aus Versehen der normale Resend-API-Key in der Umgebungsvariable. Die Signaturprüfung wirft dann bei jedem Aufruf einen Fehler, weil der API-Key mit dem pro Endpunkt vergebenen Signing Secret nichts zu tun hat. Das richtige Secret beginnt mit whsec_ und steht auf der Detailseite des jeweiligen Webhook-Endpunkts im Dashboard.
- Beim Anlegen des Endpunkts bleiben die Ereignistypen auf der Werkseinstellung stehen, und email.bounced wird nie ausgewählt. Bounces passieren auf dem Konto trotzdem, aber die Sperrliste bleibt leer, weil kein einziges Ereignis den Endpunkt erreicht. Die Auswahl der Events lässt sich jederzeit über die Endpunkt-Einstellungen im Dashboard nachtragen.
- Der Handler antwortet mit einem 500er, etwa weil eine unbehandelte Ausnahme die Datenbankschreibung unterbricht. Resend wertet das als gescheiterten Zustellversuch und wiederholt denselben Event nach dem festen Zeitplan: 5 Sekunden, 5 Minuten, 30 Minuten, 2 Stunden, 5 Stunden und 10 Stunden nach dem ersten Versuch. Ohne eine Prüfung auf die bereits verarbeitete svix-id schreibt jeder dieser Versuche einen weiteren Sperrlisteneintrag oder erhöht den Soft-Bounce-Zähler mehrfach für denselben Vorfall.
- Eine einzelne E-Mail mit dem Ereignis email.delivery_delayed sperrt die Adresse sofort, weil der Code dieselbe Regel wie für email.bounced anwendet. Nutzer mit einem kurzzeitig vollen Postfach verschwinden dadurch dauerhaft aus dem Verteiler, obwohl ihr Postfach am nächsten Tag längst wieder Platz hat. Ein Zähler, der erst nach mehreren aufeinanderfolgenden Vorfällen sperrt, verhindert diesen Effekt.
- Adressen werden ohne Normalisierung gespeichert, sodass Nutzer@Kunde.de und nutzer@kunde.de als zwei unterschiedliche Zeilen in der Tabelle stehen. Der Sendecode prüft dann gegen die eine Schreibweise, während der Bounce mit der anderen Schreibweise ankam, und die Sperre greift nicht. Sowohl beim Schreiben als auch beim Lesen wird die Adresse deshalb vorher auf Kleinschreibung normalisiert.
- Nach einer Rotation des Signing Secrets im Dashboard bleibt die alte Umgebungsvariable in der Produktionsumgebung stehen. Von diesem Moment an scheitert jede Signaturprüfung, weil der Handler mit einem Schlüssel rechnet, den Resend längst nicht mehr verwendet. Das neue Secret muss in jeder Umgebung aktualisiert werden, in der der Route Handler tatsächlich läuft, nicht nur lokal.
Häufig gestellte Fragen
Was passiert, wenn der Webhook-Endpunkt kurzzeitig nicht erreichbar ist?
Ein Webhook-Endpunkt, der nicht mit dem Statuscode 200 antwortet, gilt für Resend als fehlgeschlagene Zustellung. Resend wiederholt den Zustellversuch dann nach einem festen Zeitplan: 5 Sekunden, 5 Minuten, 30 Minuten, 2 Stunden, 5 Stunden und 10 Stunden nach dem ersten Versuch. Ein Route Handler, der jede verarbeitete svix-id in einer eigenen Tabelle festhält, nimmt diese Wiederholungen gefahrlos mehrfach entgegen, weil er bereits bekannte Ereignisse einfach überspringt, statt eine Adresse ein zweites Mal in die Sperrliste zu schreiben.
Reicht Resends eigene Suppression-Liste nicht bereits als Sperrliste aus?
Resend führt eine eigene, kontoweite Suppression-Liste und trägt eine Adresse dort automatisch nach einem harten Bounce oder einer Beschwerde ein, zusätzlich zu manuellen Einträgen. Ein erneuter Sendeversuch an eine dort gelistete Adresse landet nicht beim Empfänger, sondern löst laut Resend-Dokumentation das eigene Ereignis email.suppressed aus und zählt ausdrücklich nicht in die eigene Bounce-Rate hinein. Für eine Agentur mit vielen Kundendomains bleibt eine eigene Tabelle trotzdem sinnvoll, weil die kontoweite Liste keinen Zähler für aufeinanderfolgende weiche Bounces führt und keinen Anlaufpunkt bietet, um pro Kunde nachzuvollziehen, wann und warum eine bestimmte Adresse gesperrt wurde.
Woran unterscheidet der Webhook-Payload einen harten von einem temporären Bounce?
Ein harter Bounce erzeugt ausschließlich das Ereignis email.bounced, dessen verschachteltes bounce-Objekt das Feld type auf Permanent setzt, mit Unterarten wie General für eine generelle Ablehnung durch den Empfängerserver oder NoEmail für eine nicht existierende Adresse. Ein temporäres Problem wie ein volles Postfach erzeugt dagegen das eigene Ereignis email.delivery_delayed, dessen Nutzlast gar kein bounce-Objekt enthält, weil Resend die Zustellung in diesem Fall selbstständig erneut versucht. Wer im Code nach dem Feld event.type unterscheidet, statt eine Schwelle aus der Bounce-Rate zu berechnen, trifft damit automatisch die richtige Entscheidung zwischen sofortiger Sperrung und bloßem Mitzählen.