Repository navigation
Email 4/7 (PR 3): Resend webhook for bounces and complaints #422
Description
Part of #418. Read the parent first. Its Resend webhook section is the contract here. Step 4 of 7. server/ is short for apps/hocuspocus.server/.
Goal
Let Resend tell us about hard bounces, complaints, suppressions and late failures. The webhook writes through the existing record_email_bounce, which already turns off email for that user. No SQL migration.
Owner and gate
- Builds it: an agent or engineer.
- Staging checks: the maintainer runs them.
- Gate to merge: Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421 (PR 2) is merged.
Steps
- Shared bounce write. Create
server/src/lib/email/bounces.tswithrecordEmailBounce(client, event: EmailBounceEvent)(server/src/types/email.types.ts:103-108). Move the RPC call out of/bounce(server/src/api/email.ts:146-166) so both paths use it. Its log lines useto: maskEmail(email); today's lines at:168and:172log the raw address. - Module
server/src/modules/email-webhooks/. Copy the shape ofserver/src/modules/link-metadata/. Readserver/CLAUDE.md§HTTP Modules first.domain/types.ts:DeliveryEvent, per the parent.domain/resend/verifySignature.ts: pure, Web Crypto only, per the parent.domain/resend/toDeliveryEvent.ts: pure. Hard bounce meansdata.bounce.type === 'Permanent';subTypegoes intoreason. A product-mail event whosenstag is not this server'skeyNamespacemaps toignored.infra/webhookDedupe.ts:createWebhookDedupe(redis)withclaim,markDoneandrelease, per the parent's three states. Do not usewithRedisLease.http/controller.tsandhttp/router.ts:bodyLimit({ maxSize: 64 * 1024 })fromhono/body-limit, asserver/src/modules/document-content/http/controller.ts:40does. Then read the raw body, then check the signature, then claim the dedupe key. Never claim a key before the signature is valid. Apply the parent's Input rules. Every rejection gets the same 401 body and logsemail webhook signature rejected. Answer 200 on an ignored event.module.ts:init(deps: InitDeps): InitResult, whereInitResultis{ router }. Deps:webhookSecret,redis,recordEmailBounce,logger. The module reads no env.index.tsexportsinitonly.
- Mount in
server/src/index.ts:app.route('/api/email/webhooks', emailWebhooks.init({ … }).router), only whenconfig.email.webhookSecretis set. The router servesPOST /resend. With no secret, the path answers 404. email.failed: no write. Log{ err: new EmailSendError('operator', data.reason, …) }with the maskedto, soincident-email-operatorfires.- Env: add
RESEND_WEBHOOK_SECRETtoserver/src/config/env.schema.tsas an optional string, and expose it asconfig.email.webhookSecret. Add a pureresolveWebhookSecret(raw): string | nulltoserver/src/config/email.ts. It returnsnullfor a secret that does not decode to at least 24 bytes, and the boot then logsemail webhook secret invalidat error level. Product mail is never held for it. Add its cases toserver/src/config/__tests__/email.test.ts. - Alert: add
incident-email-webhook-unauthorizedtorules-app-logs.yml, per the parent's alert table.
Test (one file)
server/src/modules/email-webhooks/__tests__/unit/verifySignature.test.ts. Use a fixed published vector: the secret, headers, body and signature from the Standard Webhooks or Svix docs. Cover:
- the vector verifies;
- one changed body byte fails;
- a timestamp outside the 5 min window fails, in the past and in the future;
- a timestamp that is not a number fails;
- an
svix-idthat does not match the pattern fails.
A test that signs with our own code and then verifies proves nothing. The toDeliveryEvent branches are proven by the staging checks below.
Docs (same PR)
server/API.md: the route.server/ENV.md:RESEND_WEBHOOK_SECRET.server/src/modules/openapi/domain/paths/email.ts: the route, withsecurity: [{}]and the threesvix-*headers.docs/api/authentication.md:37: the webhook is authenticated by its signature, not the service role.docs/self-hosting/configuration.md: the webhook URL, the four events to pick, and where to find the secret.
Checks before push
bun run checkat the repo root, andbun testinserver/.- Import each
domain/file withREDIS_HOST,REDIS_PORTandDATABASE_URLunset.
Deploy
The deploy subject is exactly (build): back observability, because this PR also changes an alert rule.
Staging checks (maintainer)
The staging webhook from #420 (Ops 0) points at this route. Send each mail with POST /api/email/send-generic and the service-role key:
-
bounced@resend.devwrites oneemail_bouncesrow with typehard. -
complained@resend.devwrites one row with typecomplaint. -
suppressed@resend.devwrites one row with typehardand reasonresend-suppressed. - Resending one event from the Resend dashboard answers 200 and writes no new row.
- A request with a wrong signature gets 401.
Afterwards, reset the test rows if needed: delete from email_bounces where email like '%@resend.dev'.
Acceptance
- The staging checks above pass.
-
bun testis green, including the new file. -
GET /openapi.jsonon staging lists the route. - Every doc listed above is updated in this PR.
Exit evidence
Post as a comment here:
bun run checkandbun testoutput.- The
email_bouncesrows from the staging checks. The@resend.devtest addresses need no mask; mask any other address. - The 401 answer.
Revert
Disable the webhook endpoint in Resend first, so events wait in Svix and are not lost. Then git revert and redeploy.
Activity
[-]Email 4/7 (PR 3): Resend delivery webhook for bounces and complaints[/-][+]Email 4/7 (PR 3): Resend webhook for bounces and complaints[/+]feat(email): record Resend bounces and complaints through a signed we…
docs(openapi): match the spec to today's admin, email, media and heal…
docs: update the API and self-hosting guides for the 9 Oct backend fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Fixed and live.
043dda292,0fc3e7dfabbdaae66c(production run 37976785981, green).RESEND_WEBHOOK_SECRETis set in the host.env; without it the route answers 404 and mail still sends.