Learn how to persist Resend emails to your database for compliance and analytics.
Resend is the email API for developers. It's a fantastic service that boasts an incredible developer experience, and email delivery reliability. If you're building a platform that needs to send transactional or marketing emails at scale, Resend is a great choice.
Like many email services, Resend provides webhooks to notify you of events that happen with the emails you send.
We can use those events to store data in a database, which can be useful for a variety of reasons, including debugging, compliance, and analytics.
Resend has the following event types:
email.sent
email.delivered
email.delivery_delayed
email.complained
email.bounced
email.opened
email.clicked
An example payload looks something like this:
{
"type": "email.sent",
"created_at": "2023-02-22T23:41:12.126Z",
"data": {
"created_at": "2023-02-22T23:41:11.894719+00:00",
"email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
"from": "Acme <onboarding@resend.dev>",
"to": ["delivered@resend.dev"],
"subject": "Sending this example"
}
}
For the purposes of this tutorial, we'll create two tables — events
and clicks
. We'll use these to store the webhook events and clicks respectively.
Begin by creating a Database with Turso. You can use the Turso CLI, Dashboard, or sqlite.new to create a new database with one click.
If you'd prefer to follow along using the CLI, you can create a new database with the following command:
turso db create resend-webhook-events
Then connect to the database shell:
turso db shell resend-webhook-events
Now create two tables we need to store the Resend webhook events:
CREATE TABLE events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
email_created_at TEXT NOT NULL,
email_id TEXT NOT NULL,
email_from TEXT NOT NULL,
email_to TEXT NOT NULL,
email_subject TEXT NOT NULL
);
CREATE TABLE clicks (
id TEXT PRIMARY KEY,
event_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
link TEXT NOT NULL,
timestamp TEXT NOT NULL,
user_agent TEXT NOT NULL,
FOREIGN KEY (event_id) REFERENCES events(id)
);
Now we have a database and tables to store our events, it's now time to create a webhook handler to receive and store the events.
You can use any language or framework to create a webhook handler. For this tutorial, we'll use the Hono framework with Node.js since it's easy to get started with.
If you're adding this to an existing project, you can skip configuring Hono, but you should implement the handler logic.
npm create hono@latest
Give your project a name, and select a template — this tutorial will use nodejs
.
You should now see something inside src/index.ts
that looks like this:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => {
return c.text('Hello World!');
});
const port = 3000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port,
});
npm install @libsql/client
Next, fetch your database URL and auth token from the Turso CLI:
turso db show resend-webhook-events --url
turso db tokens create resend-webhook-events
Now inside src/index.ts
, import the @libsql/client
and create a new client:
import { createClient } from '@libsql/client';
const db = createClient({
url: '...',
authToken: '..',
});
You should use environment variables for the url
and authToken
. Depending on the framework, platform, and runtime, sourcing these environment variables may differ.
We're now ready to start processing the events from Resend. We'll configure the actual webhook in the next step, but for now, let's implement the handler.
Inside src/index.ts
we can add a new route to handle the webhook events. We'll call this /webhooks
for now:
app.post('/webhooks', async (c) => {
const { type, created_at, data } = await c.req.json();
if (!type || !created_at || !data) {
return c.json({ error: 'Invalid payload' }, 400);
}
try {
// Do something with the event...
return c.json({ message: 'Webhook received' });
} catch (err) {
return c.json({ message: 'Could not process webhook.' }, 500);
}
});
You'll notice above we destructure type
, created_at
, and data
from the request body, we'll store these in the database. If the payload is invalid, we'll return a 400 response.
const { email_id, from, to, subject, created_at: emailCreatedAt, click } = data;
const eventId = uuidv4();
await db.execute({
sql: `INSERT INTO events (id, type, created_at, email_created_at, email_id, email_from, email_to, email_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
eventId,
type,
created_at,
emailCreatedAt,
email_id,
from,
to,
subject,
],
});
You'll notice we're using uuidv4
to generate a unique ID for the events. Make sure to install that:
npm install uuid
npm install -D @types/uuid
Then import it at the top of the file:
import { v4 as uuidv4 } from 'uuid';
Next let's check if the event type
was email.clicked
and if so, insert the click data into the clicks
table:
if (type === 'email.clicked' && click) {
await db.execute({
sql: `
INSERT INTO clicks (id, event_id, ip_address, link, timestamp, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
`,
args: [
uuidv4(),
eventId,
click.ipAddress,
click.link,
click.timestamp,
click.userAgent,
],
});
}
We're almost done!
Most of the glue code is in place, parsing the webhook payload, and forwarding it onto the database. However, if you notice the example payload from earlier, the to
field is an array of email addresses. We need to store this as a string in the database, comma separated.
Here's a basic helper to transform emails into an array:
const emailToCsv = (emails: string[]) =>
Array.isArray(emails) ? emails.join(',') : emails;
Now we can use this helper to store the to
field in the database:
await db.execute({
sql: `INSERT INTO events (id, type, created_at, email_created_at, email_id, email_from, email_to, email_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
eventId,
type,
created_at,
emailCreatedAt,
email_id,
from,
emailToCsv(to),
subject,
],
});
That's it! You now have a webhook handler that can receive and store events from Resend.
Now all that's left to do is deploy your application, and tell Resend where to send events.
If you're working locally, you can use the Hookdeck CLI to tunnel webhook events from the web to your local machine.
Hono is running on port 3000. If you're using a different language or framework, the port it runs on may differ. You can create a local tunnel with the Hookdeck CLI like so:
hookdeck listen 3000 resend
You should now see something like this:
Copy the Forwarding
URL.
Now go to your Resend Dashboard and Add a new Webhook. Paste the Forwarding
URL from Hookdeck into the URL
field with the /webhooks
path:
Make sure to select the events you want to track. The API we built will handle all types, so select them all.
Now anytime the webhook is triggered, the event will be stored in your database.
Let's try out by sending an test event via curl, using the example from the Resend docs:
curl -L 'https://hkdk.events/baj0dlspl79g27/webhooks' \
-H 'Content-Type: application/json' \
--data-raw '{
"type": "email.clicked",
"created_at": "2023-02-22T23:41:12.126Z",
"data": {
"created_at": "2023-02-22T23:41:11.894719+00:00",
"email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
"from": "Acme <onboarding@resend.dev>",
"to": ["delivered@resend.dev"],
"click": {
"ipAddress": "122.115.53.11",
"link": "https://resend.com",
"timestamp": "2023-02-24T05:00:57.163Z",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"
},
"subject": "Sending this example"
}
}
'
Now let's query the database to get some stats about our stored events:
SELECT
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM clicks) as total_clicks,
(SELECT COUNT(*) FROM events WHERE type = 'email.sent') as sent_count,
(SELECT COUNT(*) FROM events WHERE type = 'email.delivered') as delivered_count,
(SELECT COUNT(*) FROM events WHERE type = 'email.opened') as opened_count;
You should see something like this:
TOTAL EVENTS TOTAL CLICKS SENT COUNT DELIVERED COUNT OPENED COUNT
1 1 2 0 0
You now have a copy of each event sent in your own database that you can query and analyze as needed.
In this tutorial, we built a webhook handler that can receive events from Resend and store them in a database. We used @libsql/client
to interact with the database and uuid
to generate unique IDs for events and clicks.
Keep in mind that this is a basic example running locally using the Hookdeck CLI. In a production environment, you should consider:
svix