Radondocs

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.

One handler for every provider
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 & verifiedNormalized, unsignedNo delivery webhook
Resend, SendGrid, SES, Mailgun, MailerSendPostmark, Brevo, Mailjet, SparkPostTermii, 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 are true.
  • 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 throws UnsupportedOperationError. Both flags are false.

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.

app/api/webhooks/email/route.ts
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:

.env
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 secret

Amazon 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:

deliveredEmailEventType

The message reached the recipient's mail server.

openedEmailEventType

The recipient opened the message.

clickedEmailEventType

The recipient clicked a link — the URL is on event.url.

bouncedEmailEventType

The recipient's server rejected it. event.bounce.type is "hard" or "soft".

complainedEmailEventType

The recipient marked it as spam.

unsubscribedEmailEventType

The recipient unsubscribed.

droppedEmailEventType

The provider suppressed it before sending (invalid/suppressed address).

deferredEmailEventType

Temporary failure — the provider will retry.

failedEmailEventType

Permanent send failure.

unknownEmailEventType

A provider event Radon doesn't model — the original payload is on event.raw.

The EmailEvent shape

typeEmailEventType

The normalized event type — branch on this.

providerstring

Which provider delivered the webhook.

idstring | undefined

The provider's event id, for idempotent handling / dedupe.

recipientstring | undefined

The recipient address this event concerns.

messageIdstring | undefined

The message id — correlates to SendResult.id.

urlstring | undefined

For 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[] | undefined

Tags the original message carried, when the provider echoes them.

providerEventTypestring

The provider's original event name, verbatim (e.g. "delivery", "open").

occurredAtDate

Event time reported by the provider, else receipt time.

rawunknown

The 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(...):

Centralize event handling
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[]>Pro

Verify 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 | Bufferrequired

The raw request body exactly as received — never a parsed object.

request.headersRecord<string, string | string[] | undefined>required

The request headers.

options.providerstring

Which provider sent this. Required when more than one is configured.

options.secretstring

Override the provider's configured webhook secret for this call.

options.dispatchbooleandefault: true

Fan the events out to registered lifecycle hooks. Set false to parse only.

Next steps

On this page