$npx -y skills add hookdeck/webhook-skills --skill resend-webhooksReceive and verify Resend webhooks. Use when setting up Resend webhook handlers, debugging signature verification, handling email events like email.sent, email.delivered, email.bounced, or processing inbound emails.
| 1 | # Resend Webhooks |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | - Setting up Resend webhook handlers |
| 6 | - Debugging signature verification failures |
| 7 | - Understanding Resend event types and payloads |
| 8 | - Handling email delivery events (sent, delivered, bounced, etc.) |
| 9 | - Processing inbound emails via `email.received` events |
| 10 | |
| 11 | ## Essential Code (USE THIS) |
| 12 | |
| 13 | ### Express Webhook Handler (Using Resend SDK) |
| 14 | |
| 15 | ```javascript |
| 16 | const express = require('express'); |
| 17 | const { Resend } = require('resend'); |
| 18 | |
| 19 | const resend = new Resend(process.env.RESEND_API_KEY); |
| 20 | const app = express(); |
| 21 | |
| 22 | // CRITICAL: Use express.raw() for webhook endpoint - Resend needs raw body |
| 23 | app.post('/webhooks/resend', |
| 24 | express.raw({ type: 'application/json' }), |
| 25 | async (req, res) => { |
| 26 | try { |
| 27 | // Verify signature using Resend SDK (uses Svix under the hood) |
| 28 | const event = resend.webhooks.verify({ |
| 29 | payload: req.body.toString(), |
| 30 | headers: { |
| 31 | id: req.headers['svix-id'], // Note: short key names |
| 32 | timestamp: req.headers['svix-timestamp'], |
| 33 | signature: req.headers['svix-signature'], |
| 34 | }, |
| 35 | webhookSecret: process.env.RESEND_WEBHOOK_SECRET // whsec_xxxxx |
| 36 | }); |
| 37 | |
| 38 | // Handle the event |
| 39 | switch (event.type) { |
| 40 | case 'email.sent': |
| 41 | console.log('Email sent:', event.data.email_id); |
| 42 | break; |
| 43 | case 'email.delivered': |
| 44 | console.log('Email delivered:', event.data.email_id); |
| 45 | break; |
| 46 | case 'email.bounced': |
| 47 | console.log('Email bounced:', event.data.email_id); |
| 48 | break; |
| 49 | case 'email.received': |
| 50 | console.log('Email received:', event.data.email_id); |
| 51 | // For inbound emails, fetch full content via API |
| 52 | break; |
| 53 | default: |
| 54 | console.log('Unhandled event:', event.type); |
| 55 | } |
| 56 | |
| 57 | res.json({ received: true }); |
| 58 | } catch (err) { |
| 59 | console.error('Webhook verification failed:', err.message); |
| 60 | return res.status(400).send(`Webhook Error: ${err.message}`); |
| 61 | } |
| 62 | } |
| 63 | ); |
| 64 | ``` |
| 65 | |
| 66 | ### Express Webhook Handler (Manual Verification) |
| 67 | |
| 68 | For manual verification without the SDK, or for other languages: |
| 69 | |
| 70 | ```javascript |
| 71 | const express = require('express'); |
| 72 | const crypto = require('crypto'); |
| 73 | |
| 74 | const app = express(); |
| 75 | |
| 76 | function verifySvixSignature(payload, headers, secret) { |
| 77 | const msgId = headers['svix-id']; |
| 78 | const msgTimestamp = headers['svix-timestamp']; |
| 79 | const msgSignature = headers['svix-signature']; |
| 80 | |
| 81 | if (!msgId || !msgTimestamp || !msgSignature) return false; |
| 82 | |
| 83 | // Check timestamp (5 min tolerance) |
| 84 | const now = Math.floor(Date.now() / 1000); |
| 85 | if (Math.abs(now - parseInt(msgTimestamp)) > 300) return false; |
| 86 | |
| 87 | // Remove 'whsec_' prefix and decode secret |
| 88 | const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64'); |
| 89 | |
| 90 | // Compute expected signature |
| 91 | const signedContent = `${msgId}.${msgTimestamp}.${payload}`; |
| 92 | const expectedSig = crypto |
| 93 | .createHmac('sha256', secretBytes) |
| 94 | .update(signedContent) |
| 95 | .digest('base64'); |
| 96 | |
| 97 | // Check against provided signatures |
| 98 | for (const sig of msgSignature.split(' ')) { |
| 99 | if (sig.startsWith('v1,') && sig.slice(3) === expectedSig) return true; |
| 100 | } |
| 101 | return false; |
| 102 | } |
| 103 | |
| 104 | app.post('/webhooks/resend', |
| 105 | express.raw({ type: 'application/json' }), |
| 106 | (req, res) => { |
| 107 | const payload = req.body.toString(); |
| 108 | |
| 109 | if (!verifySvixSignature(payload, req.headers, process.env.RESEND_WEBHOOK_SECRET)) { |
| 110 | return res.status(400).send('Invalid signature'); |
| 111 | } |
| 112 | |
| 113 | const event = JSON.parse(payload); |
| 114 | // Handle event... |
| 115 | res.json({ received: true }); |
| 116 | } |
| 117 | ); |
| 118 | ``` |
| 119 | |
| 120 | ### Python (FastAPI) Webhook Handler |
| 121 | |
| 122 | ```python |
| 123 | import os |
| 124 | import hmac |
| 125 | import hashlib |
| 126 | import base64 |
| 127 | import time |
| 128 | from fastapi import FastAPI, Request, HTTPException |
| 129 | |
| 130 | app = FastAPI() |
| 131 | webhook_secret = os.environ.get("RESEND_WEBHOOK_SECRET") |
| 132 | |
| 133 | def verify_svix_signature(payload: bytes, headers: dict, secret: str) -> bool: |
| 134 | """Verify Svix signature (used by Resend).""" |
| 135 | msg_id = headers.get("svix-id") |
| 136 | msg_timestamp = headers.get("svix-timestamp") |
| 137 | msg_signature = headers.get("svix-signature") |
| 138 | |
| 139 | if not all([msg_id, msg_timestamp, msg_signature]): |
| 140 | return False |
| 141 | |
| 142 | # Check timestamp (5 min tolerance) |
| 143 | if abs(int(time.time()) - int(msg_timestamp)) > 300: |
| 144 | return False |
| 145 | |
| 146 | # Remove 'whsec_' prefix and decode base64 |
| 147 | secret_bytes = base64.b64decode(secret.replace("whsec_", "")) |
| 148 | |
| 149 | # Create signed content |
| 150 | signed_content = f"{msg_id}.{msg_timestamp}.{payload.decode()}" |
| 151 | |
| 152 | # Compute expected signature |
| 153 | expected = base64.b64encode( |
| 154 | hmac.new(secret_bytes, signed_ |