For most people, email is a UI experience. You open Gmail or Yahoo, click Compose, write a subject and body, add a recipient, and hit send.
From an application perspective, email behaves more like infrastructure. Messages pass through external providers, delivery happens asynchronously, and a single send can trigger multiple downstream events.
But if email is infrastructure, can it be made observable like the rest of an application?
Yes. By converting email lifecycle events into telemetry.
This guide shows how to do that. Using Resend webhooks and OpenTelemetry (OTel), email events (sent, delivered, bounced, complained) become traces and metrics visible alongside the rest of your application telemetry.
How It Works
Resend fires a webhook for every email event. A lightweight webhook receiver picks it up and emits a OpenTelemetry span and metric. This guide uses SigNoz as the observability backend, but the same setup should work with any OTLP compatible vendor.
Your App → Resend API
↓
Resend sends email
↓
Email event occurs
(delivered / bounced / complained)
↓
Resend fires webhook
↓
Webhook receiver (index.js)
emits spans + metrics
↓
SigNoz / OTel Vendor
Interesting insight 💡
Because this approach stores Resend webhook events as traces and metrics in the observability backend, you get webhook retention as part of your observability data for free. On SigNoz Cloud that is 15 days for traces and 30 days for metrics. On self-hosted SigNoz you can also move older data to cold storage.
Prerequisites
Node.js v18+ installed
SigNoz Cloud account (self-hosted works too)
ngrok to expose your local webhook endpoint
The Sample App
The sample app is a Node.js app that simulates a user registration flow, one of the most common transactional email use cases. When a user registers, the app sends a welcome email built with React Email via Resend.
You can find the full code in the GitHub repository. Clone it and install dependencies:
git clone https://github.com/Calm-Rock/resend-email-observability.git
cd resend-email-observability
npm install
The OTel packages required for tracing and metrics are already included in package.json and will be installed automatically.
What OTel packages are installed?
The sample app (sample-app.js) has three endpoints:
POST /register→ sends a welcome email to a real addressPOST /register/bounce→ triggers a bounce scenarioPOST /register/spam→ triggers a spam complaint scenario
Copy .env.example to .env
cp .env.example .env
And add your Resend API key (starts with re_) and sender email.
RESEND_API_KEY=your_api_key
SENDER_EMAIL=you@yourdomain.com
yourdomain.com should be a domain you have verified in Resend. If you don’t have a verified domain yet, you can use onboarding@resend.dev as the sender email for testing. However, emails sent from this address can only be delivered to the email address associated with your Resend account.
Run the sample app:
node sample-app.js
This will start the sample app on port 3001.
Send a test email
Send a test email to verify the sample app is working:
curl -X POST http://localhost:3001/register \
-H "Content-Type: application/json" \
-d '{"name": "Your Name", "email": "your@email.com"}'
Replace your@email.com with the email address where you want to receive the test welcome email. You will receive the welcome email as shown below.
The Webhook Receiver
The webhook receiver is a single Express app (index.js) that listens for Resend webhook events and converts them into spans and metrics.
When an email is sent through Resend, webhook events are triggered for each state change in the email lifecycle. Each payload contains a type field describing the event, such as email.sent, email.delivered, or email.bounced, and a data object with details about the email. See the full list of event types.
Here is an example email.sent payload:
{
"created_at": "2026-03-05T20:18:44.871Z",
"data": {
"created_at": "2026-03-05T20:18:44.739Z",
"email_id": "095e18f0-ef44-4327-a083-a2ec5a19a27c",
"from": "onboarding@resend.dev",
"subject": "Welcome to Forge — your workspace is ready",
"to": [
"test@gmail.com"
]
},
"type": "email.sent"
}
Setting up the webhook endpoint
The webhook receiver listens for POST requests on /webhook. When a Resend event arrives, it extracts the email details from the payload, creates an OTel span and increments a metric counter.
The complete code for the webhook receiver is in the index.js file in the resend-email-observability repository you cloned earlier.
⚠️ NOTE
For simplicity this example does not verify webhook signatures. In production environments you should verify Resend webhook signatures to ensure requests originate from Resend and prevent spoofed events.
Here is a walkthrough of the key sections:
Parsing the webhook payload
Each incoming webhook request is parsed to extract the event type and relevant email details.
const event = req.body;
const eventType = event.type;
const data = event.data;
const emailId = data?.email_id;
const toEmail = data?.to?.[0] ?? "unknown";
const domain = extractDomain(toEmail);
const bounceType = data?.bounce?.type ?? null;
const bounceSubType = data?.bounce?.subType ?? null;
eventType is the event name like email.bounced. bounceType and bounceSubType are only present in bounce events and contain values like Permanent or Transient.
Initializing the tracer and meter
const tracer = trace.getTracer("resend-webhook-receiver");
const meter = metrics.getMeter("resend-webhook-receiver");
const emailEventCounter = meter.createCounter("email.events", {
description: "Count of Resend email events by type",
});
This sets up the OTel tracer for creating spans and a counter metric that will track email events by type.
Traces let you debug individual emails, while metrics power aggregate dashboards. The webhook receiver emits both for every event.
Creating a span for each email event
const span = tracer.startSpan(`resend.${eventType}`);
const attributes = {
"email.event_type": eventType,
"email.id": emailId,
"email.to": toEmail,
"email.domain": domain,
"email.from": data?.from ?? "unknown",
"email.subject": data?.subject ?? "unknown",
"email.timestamp": data?.created_at ?? "unknown",
};
if (bounceType) attributes["email.bounce_type"] = bounceType;
if (bounceSubType) attributes["email.bounce_subtype"] = bounceSubType;
span.setAttributes(attributes);
Each webhook event becomes a span with a name like resend.email.bounced. Email details are attached as attributes such as email.id, email.to, email.domain, email.subject, along with event specific attributes like email.bounce_type for bounce events.
Incrementing the metric counter
const metricAttributes = {
"email.event_type": eventType,
"email.domain": domain,
};
if (bounceType) metricAttributes["email.bounce_type"] = bounceType;
emailEventCounter.add(1, metricAttributes);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
res.status(200).json({ received: true });
Every event increments the email.events counter tagged with the event type and recipient domain as dimensions. Bounce events also include email.bounce_type as a metric attribute, so you can track hard and soft bounces separately on your dashboard. After the metric is recorded, span.setStatus marks the span as successful and span.end() closes it. This is important because spans are only exported after they complete.
Returning a 200 response is important because Resend uses it to confirm the webhook was received. If your endpoint returns a non-2xx status, Resend will retry delivery.
Exposing webhook receiver with ngrok
Resend needs a public URL to deliver webhook events to. During local development, you can expose your server using ngrok:
ngrok http 3000
The webhook receiver runs on port 3000 . The ngrok command above exposes it as a public HTTPS URL that Resend can deliver webhooks to.
Copy the HTTPS forwarding URL from the ngrok output:
Forwarding https://your-id.ngrok-free.app -> http://localhost:3000
https://your-id.ngrok-free.app is your forwarding URL. Append /webhook to it:
https://your-id.ngrok-free.app/webhook
Make sure the endpoint URL ends with /webhook, since that is the route the receiver listens on.
This is the URL you will add to Resend in the next step.
Configuring Resend webhooks
Go to the Resend Webhooks dashboard and click Add Webhook. Paste your ngrok webhook URL (with /webhook) and select All Events so your endpoint receives every webhook event.
Running the Webhook Receiver
Start the webhook receiver with OpenTelemetry configured to export to SigNoz. Run the below commands in the root folder:
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=<your-ingestion-endpoint>:443
export OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=<your-ingestion-key>"
export OTEL_EXPORTER_OTLP_TIMEOUT=5000
export OTEL_NODE_RESOURCE_DETECTORS=env,host,os
export OTEL_SERVICE_NAME=resend-email-observability
export NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register"
node index.js
These environment variables configure the OTel SDK without any code changes. OTEL_EXPORTER_OTLP_ENDPOINT tells the SDK where to send data, OTEL_SERVICE_NAME is how your service will appear in SigNoz, and NODE_OPTIONS loads the auto-instrumentation library which automatically captures HTTP spans alongside your custom email spans. Detailed explanation about the OTel environment variables can be found in the OpenTelemetry docs for SDK configuration and OTLP exporter configuration.Click to know details about the OTEL environment variables
Replace <your-ingestion-key> with your SigNoz ingestion key and <your-ingestion-endpoint> with your SigNoz ingestion endpoint, For example, https://ingest.us.signoz.cloud .
Note: For Self-Hosted version of SigNoz, update the endpoint and remove the ingestion key header as shown here.
In a separate terminal, start the sample application by running this command in the root folder:
node sample-app.js
Triggering email events
The sample application has three endpoints, each simulating a different email delivery outcome.
/register→ triggersemail.sentandemail.deliveredevents/register/bounce→ triggersemail.sentandemail.bouncedevents/register/spam→ triggersemail.sentandemail.complainedevents
Trigger all three scenarios:
curl -X POST http://localhost:3001/register \
-H "Content-Type: application/json" \
-d '{"name": "Test User", "email": "your@email.com"}'
curl -X POST http://localhost:3001/register/bounce \
-H "Content-Type: application/json" \
-d '{"name": "Test User"}'
curl -X POST http://localhost:3001/register/spam \
-H "Content-Type: application/json" \
-d '{"name": "Test User"}'
Replace your@email.com with your own email address in the first command.
You should see webhook events arriving in your terminal:
These events are now being converted into OTel spans and metrics. Let's see them in SigNoz.
Viewing Traces in SigNoz
Head over to SigNoz and click on Traces. Filter by service.name = resend-email-observability and you should see a trace for the different webhook events.
Click on any trace to see the full span details. Here is what a resend.email.bounced span looks like:

The trace shows a POST /webhook request flowing through the JSON parser middleware into the webhook handler, which creates the resend.email.bounced child span with all email attributes attached.
Every email attribute attached in index.js is visible here — email.id, email.to, email.domain, email.bounce_type and more. This means you can:
Filter traces by
email.bounce_type = Permanentto find all hard bouncesFilter by
email.domainto see which domains are causing issuesFilter by
email.idto trace the full lifecycle of a single email across multiple events
Custom Dashboard
Traces help you debug individual emails. A dashboard shows the aggregate picture.
A ready-to-import SigNoz dashboard JSON is available here. Import it from Dashboards → New Dashboard → Import JSON.
The dashboard includes panels for email event volume over time, distribution by event type, bounce type breakdown, events by recipient domain, and HTTP endpoint latency and status codes from auto-instrumentation.
Each panel includes a description. Hover over the ⓘ icon in your Dashboard to read it.
Here is what the dashboard looks like:
One thing worth highlighting is that SigNoz dashboards are interactive. Clicking any data point in a panel lets you jump directly to the related traces, so you can move from aggregate metrics to individual email spans in a single click.
Troubleshooting
If events are not appearing as expected, use this table to diagnose the issue.
| Symptom | Likely Cause | Fix |
|---|---|---|
| No data in SigNoz, no errors in terminal | Wrong ingestion endpoint or key | Double-check both values against your SigNoz ingestion settings |
| Webhook events log in terminal but no traces in SigNoz | Incorrect OTEL_SERVICE_NAME or endpoint |
Verify all export commands ran in the same terminal session before node index.js
|
| Events worked before, now nothing arrives | ngrok URL changed after a session restart | Update the webhook URL in the Resend Webhooks dashboard |
| Email sends but no webhook event logged in terminal | Resend webhook not configured or pointed at wrong URL | Check the Resend Webhooks dashboard for delivery attempts and errors |
email.delivered never arrives |
Delivery confirmation can take several minutes | Wait for some time, or check Resend logs for delivery status |
email.bounced or email.complained not triggering |
Test endpoints use hardcoded Resend test addresses | Use /register/bounce and /register/spam as-is — no email field needed |
Going Further
Alerting on bounce spikes
SigNoz lets you set up alerts on any metric. A useful one is alerting when the bounce rate crosses a threshold.
For example, if email.bounced events exceed a set threshold in a 5 minute window, you can configure this from Alerts → New Alert → Metrics Alert in SigNoz using the email.events metric filtered by email.event_type = 'email.bounced'.
Using the Docker image
If you'd rather not install Node.js, you can run the webhook receiver as a Docker container using a pre-built Docker image . A Dockerfile is also included in the repository if you want to customize it.
Run the pre-built image:
docker run -p 3000:3000 \
-e OTEL_EXPORTER_OTLP_ENDPOINT=<your-ingestion-endpoint>:443 \
-e OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=<your-ingestion-key>" \
-e OTEL_EXPORTER_OTLP_TIMEOUT=5000 \
-e OTEL_SERVICE_NAME=resend-email-observability \
chitranshgupta/resend-webhook-receiver
Replace <your-ingestion-key> with your SigNoz ingestion key , <your-ingestion-endpoint> with your SigNoz ingestion endpoint. For example, https://ingest.us.signoz.cloud .
Conclusion
Most teams find out about email delivery problems when users complain. With Resend webhooks and OpenTelemetry, you find out the moment it happens in the same place you already watch the rest of your stack.






Top comments (0)