Webhooks
One verified handler for every provider's delivery events. The three signature tiers, the raw-body requirement, provider disambiguation, and the normalized event schema.
Pro Providers notify you of delivery events — opens, clicks,
bounces, complaints, unsubscribes — via webhooks, each with its own payload
shape and (sometimes) its own signature scheme. email.webhooks.handle()
verifies the signature inside the adapter and hands you one normalized
EmailEvent[], so you write a single handler no matter which providers are
live. Webhook normalization is a Pro feature — call await email.init() first.
const events = await email.webhooks.handle({
body: req.rawBody, // the RAW bytes — see below
headers: req.headers,
});
for (const e of events) {
switch (e.type) {
case "delivered": break;
case "bounced": /* e.bounce?.type is "hard" | "soft" */ break;
case "complained": break;
case "unsubscribed": break;
case "opened":
case "clicked": break;
}
}The three signature tiers
Providers fall into three groups. capabilities.webhooks and
capabilities.webhookSignature tell you which tier a provider is in.
| Signed & verified | Normalized, unsigned | No delivery webhook |
|---|---|---|
| Resend, SendGrid, SES, Mailgun, MailerSend | Postmark, Brevo, Mailjet, SparkPost | Termii, Loops, Elastic Email, SMTP2GO, Pinpoint, ZeptoMail, SMTP |
- Signed & verified — Radon cryptographically verifies the signature
before normalizing (Svix, ECDSA, SNS-RSA, HMAC-SHA256). A bad signature throws
WebhookSignatureError. Both capability flags aretrue. - Normalized, unsigned — the provider ships no body signature. Radon still
normalizes the payload, but there's nothing to verify, so you must secure
the endpoint (a secret URL path, basic auth, an IP allowlist).
webhooks: true,webhookSignature: false. - No delivery webhook — the provider has no signed delivery webhook Radon
can normalize.
handle()for these throwsUnsupportedOperationError. Both flags arefalse.
Secure unsigned endpoints yourself
For Postmark, Brevo, Mailjet, and SparkPost there is no signature to check. Radon returns normalized events, but treat the endpoint as public unless you add your own protection — a hard-to-guess path or basic auth is the usual approach.
Always pass the raw body
Signatures are computed over the exact bytes the provider sent. If your
framework has already parsed the body into an object and re-serialized it,
verification will fail. Give Radon the raw string or Buffer.
import { email } from "@/lib/email";
export async function POST(req: Request) {
const body = await req.text(); // raw string — do NOT req.json()
const headers = Object.fromEntries(req.headers);
try {
const events = await email.webhooks.handle(
{ body, headers },
{ provider: "resend" },
);
// handle events…
return new Response(null, { status: 200 });
} catch {
return new Response(null, { status: 400 }); // signature failed → reject
}
}Throw means reject, events mean accept
Treat any throw from handle() as respond 400 (reject the request) and a
returned event array as respond 200. A provider batches many events per
POST, which is why you always get an array.
Configure the signing secret
Each signed provider needs its verification secret, read from the provider's
webhookSecret option or a RADON_<PROVIDER>_* env var:
RADON_RESEND_WEBHOOK_SECRET=whsec_... # Svix signing secret
RADON_SENDGRID_WEBHOOK_VERIFICATION_KEY=... # base64 ECDSA public key
RADON_MAILGUN_WEBHOOK_SECRET=... # HTTP webhook signing key
RADON_MAILERSEND_WEBHOOK_SECRET=... # HMAC secretAmazon SES is the exception: it needs no configured secret. Its events
arrive via SNS, and Radon verifies each message against the RSA certificate at
the SNS SigningCertURL (restricted to *.amazonaws.com). You can override any
provider's secret per call with options.secret.
SES SNS subscription handshake
The first time an SNS topic delivers, it sends a SubscriptionConfirmation
rather than a notification. Radon verifies and returns it as a single event with
type: "unknown" and providerEventType: "SubscriptionConfirmation" — read
event.raw.SubscribeURL and visit it once to confirm the subscription.
Disambiguating the provider
With one provider configured, Radon infers it — options.provider is
optional. With several, you must say which adapter should verify the
request, or handle() throws InvalidConfigError.
// One provider configured → inferred:
await email.webhooks.handle({ body, headers });
// Several configured → name the one this endpoint belongs to:
await email.webhooks.handle({ body, headers }, { provider: "sendgrid" });The usual pattern is one route per provider (/webhooks/resend,
/webhooks/sendgrid), each passing its own provider.
Normalized event types
Every provider's events map into one vocabulary — branch on event.type:
deliveredEmailEventTypeThe message reached the recipient's mail server.
openedEmailEventTypeThe recipient opened the message.
clickedEmailEventTypeThe recipient clicked a link — the URL is on event.url.
bouncedEmailEventTypeThe recipient's server rejected it. event.bounce.type is "hard" or
"soft".
complainedEmailEventTypeThe recipient marked it as spam.
unsubscribedEmailEventTypeThe recipient unsubscribed.
droppedEmailEventTypeThe provider suppressed it before sending (invalid/suppressed address).
deferredEmailEventTypeTemporary failure — the provider will retry.
failedEmailEventTypePermanent send failure.
unknownEmailEventTypeA provider event Radon doesn't model — the original payload is on
event.raw.
The EmailEvent shape
typeEmailEventTypeThe normalized event type — branch on this.
providerstringWhich provider delivered the webhook.
idstring | undefinedThe provider's event id, for idempotent handling / dedupe.
recipientstring | undefinedThe recipient address this event concerns.
messageIdstring | undefinedThe message id — correlates to SendResult.id.
urlstring | undefinedFor clicked events, the URL that was clicked.
bounce{ type?: 'hard' | 'soft'; reason?: string }For bounced events, hard vs. soft plus the provider's reason.
tagsstring[] | undefinedTags the original message carried, when the provider echoes them.
providerEventTypestringThe provider's original event name, verbatim (e.g. "delivery", "open").
occurredAtDateEvent time reported by the provider, else receipt time.
rawunknownThe full, untouched provider payload for this single event.
React with hooks instead
By default handle() also dispatches each event to any registered lifecycle
hooks — a clean way to keep bounce-suppression logic out of your route. Register
handlers with email.on(...):
email.on("onBounced", (e) => suppress(e.recipient));
email.on("onComplained", (e) => suppress(e.recipient));
email.on("onUnsubscribed", (e) => unsubscribe(e.recipient));
email.on("onEmailEvent", (e) => log(e)); // catch-all, fires for every event
// The route just verifies and returns — hooks do the work.
await email.webhooks.handle({ body, headers }, { provider: "resend" });Pass { dispatch: false } to handle() if you want to parse only and skip the
hooks. Hook failures are isolated — a throwing handler is reported via
onEventError and never breaks the request.
handle() signature
email.webhooks.handle(request: WebhookRequest, options?: HandleWebhookOptions): Promise<EmailEvent[]>ProVerify and normalize an inbound webhook, then (by default) dispatch its events
to hooks. Throws WebhookSignatureError on a bad signature,
UnsupportedOperationError for a provider with no webhook, and
InvalidConfigError if several providers are configured and none is named.
request.bodystring | BufferrequiredThe raw request body exactly as received — never a parsed object.
request.headersRecord<string, string | string[] | undefined>requiredThe request headers.
options.providerstringWhich provider sent this. Required when more than one is configured.
options.secretstringOverride the provider's configured webhook secret for this call.
options.dispatchbooleandefault: trueFan the events out to registered lifecycle hooks. Set false to parse only.
Next steps
Attachments & scheduling
Attach files as Buffers or base64, embed inline images with content IDs, and schedule sends with sendAt — plus which providers support each.
Providers
All 16 email providers — required credentials, capabilities, and the real quirks Radon absorbs for you. Free (Resend, SendGrid, SMTP) and Pro (the other 13).