Handling bounces
Hear about bounces and spam complaints as they happen, and stop mailing those addresses.
Mail to addresses that do not exist, or to people who report you as spam, is what costs a domain its reputation. Rovela already protects you from the worst of it — complaints and bounces for mailboxes that do not exist put the address on your suppression list, and later sends to it are refused. What it cannot do is update your database. That is what this webhook is for.
1. Subscribe
Creating a webhook needs a full_access key or the dashboard's Webhooks page.
curl -X POST https://api.rovela.dev/webhooks \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://app.acme.com/webhooks/rovela",
"events": ["email.bounced", "email.complained", "suppression.added"]
}'The response carries signing_secret, shown only this once. Store it as ROVELA_WEBHOOK_SECRET.
2. What arrives
{
"id": "evt_5a1c...",
"type": "email.bounced",
"created_at": "2026-10-05T08:12:40.118+00:00",
"data": {
"email_id": "9f1c2e40-...",
"recipient": "old-address@example.com",
"reason": "550 5.1.1 <old-address@example.com>: Recipient address rejected: User unknown"
}
}Depending on where the bounce was detected, the address is in data.recipient or in data.to (an array, for a refusal at send time). Read both. suppression.added carries {suppression_id, email, kind}, with kind being bounced or complained.
3. The handler
A Next.js route handler. The signature check is the one from Security.
import crypto from "node:crypto"
export async function POST(request) {
const rawBody = Buffer.from(await request.arrayBuffer())
const sig = request.headers.get("x-resend-signature") ?? ""
if (!verify(process.env.ROVELA_WEBHOOK_SECRET, sig, rawBody)) {
return new Response("invalid signature", { status: 400 })
}
const event = JSON.parse(rawBody.toString("utf8"))
// Retries resend the same event id. Recording it first makes the
// handler safe to run twice.
const fresh = await db.query(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
[event.id]
)
if (fresh.rowCount === 0) return new Response(null, { status: 200 })
const d = event.data
const addresses = [d.recipient ?? d.to ?? d.email].flat().filter(Boolean)
switch (event.type) {
case "email.bounced":
// Stop asking the user to "check their inbox" -- ask for a new address.
await db.query(
"UPDATE users SET email_status = 'bouncing', email_bounce_reason = $2 WHERE email = ANY($1)",
[addresses, d.reason]
)
break
case "email.complained":
// They called it spam. Turn off everything optional for them.
await db.query(
"UPDATE users SET marketing_opt_in = false WHERE email = ANY($1)",
[addresses]
)
break
case "suppression.added":
await db.query(
"UPDATE users SET email_suppressed = true WHERE email = ANY($1)",
[addresses]
)
break
}
return new Response(null, { status: 200 })
}
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
if (!parts.t || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = crypto.createHmac("sha256", secret)
.update(parts.t + ".").update(rawBody).digest("hex")
const a = Buffer.from(expected), b = Buffer.from(parts.v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}500 if your database is down, not 400. Deliveries are retried up to five times, but only after a 5xx, 408 or 429; any other 4xx tells us the event is unwanted, and it is dropped.4. The suppression list
These endpoints also need a full_access key. Look an address up — useful for a support agent asking "why does this customer get nothing?":
curl "https://api.rovela.dev/emails/suppressions?email=old-address@example.com" \
-H "Authorization: Bearer re_your_full_access_key"Add one yourself, for example when a user deletes their account or asks you never to write again:
curl -X POST https://api.rovela.dev/emails/suppressions \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{"email": "leaving@example.com", "kind": "unsubscribed", "reason": "account deleted"}'Removing a suppression
DELETE /emails/suppressions/:id removes only entries you added (origin manual). Ones created by a bounce or a complaint stay: the address really did bounce, or the person really did complain, and quietly mailing them again is how a domain ends up blocked.
What gets suppressed automatically
- Complaints — always.
- Bounces — only when the bounce report says the mailbox does not exist. A full mailbox, a policy rejection or a temporary failure is still reported as
email.bounced, but the address stays mailable, because it is probably valid. So does a refusal at send time (the event withdata.to).
That second case is why the handler above records every bounce rather than relying on suppression alone: repeated soft bounces to the same address are worth acting on too.