Resend Plugin
Receive and verify Resend webhooks with Restaq.
The @restaq/resend plugin handles Resend webhook verification and provides typed Restaq event names for Resend email events. Like Clerk, Resend delivers webhooks via Svix, so verification follows the same Svix signature scheme.
Installation
pnpm add @restaq/resendnpm install @restaq/resendyarn add @restaq/resendbun add @restaq/resendimport { resend } from '@restaq/resend';Setup
// relay.ts
import { restaq as createRestaq } from 'restaq';
import { resend } from '@restaq/resend';
export const restaq = createRestaq({
plugins: [
resend(), // reads RESEND_WEBHOOK_SECRET automatically
],
});- In the Resend Dashboard → Webhooks, add an endpoint pointing at
/api/webhook/resendon your deployed app and pick the events you want. - Copy the Signing Secret (starts with
whsec_) and set it asRESEND_WEBHOOK_SECRET.
Pass webhookSecret explicitly instead if you'd rather not rely on the env var:
resend({ webhookSecret: 'whsec_...', toleranceSeconds: 300 }),Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| webhookSecret | string | process.env.RESEND_WEBHOOK_SECRET | The endpoint's signing secret from the Resend dashboard |
| toleranceSeconds | number | 300 | Max age of a signed timestamp before rejection |
Signature verification
Resend signs webhooks the way Svix does: three headers (svix-id, svix-timestamp, svix-signature) instead of Stripe/GitHub's single-header scheme.
- Reads the
svix-id,svix-timestamp, andsvix-signatureheaders — rejects the request if any are missing - Rejects if the timestamp is older than
toleranceSeconds - Computes HMAC-SHA256 of
${svix-id}.${svix-timestamp}.${rawBody}, using the webhook secret's base64-decoded bytes after stripping itswhsec_prefix svix-signaturecan contain multiple space-separatedv1,<signature>values (for secret rotation) — the request is valid if any of them match, compared using constant-time comparison
Event types
Events are namespaced as resend.<resend-event-type> — see Webhook event types for the full list Resend can send:
// relay.handlers.ts
import type { AppRelay } from './relay';
export function registerHandlers(relay: AppRelay): void {
relay.on('resend.email.delivered', async (event, ctx) => {
ctx.log.info('email delivered', { id: event.data.email_id });
});
relay.on('resend.email.bounced', async (event, ctx) => {
ctx.log.warn('email bounced', { id: event.data.email_id });
});
}Deduplication
Every Svix-delivered webhook carries a svix-id header that stays the same across delivery retries. The plugin uses it as the Restaq event ID, so a redelivered event collapses into the same execution instead of running your handler twice.
Testing locally
Use relay trigger to simulate a signed Resend webhook:
relay trigger resend email.delivered --data '{"email_id":"email_123"}'Make sure RESEND_WEBHOOK_SECRET is set to the same value on both relay dev and relay trigger, or unset on both — see the Local Development gotcha for why.