DEV Community

Cover image for Email Is Infrastructure. Start Treating It Like One.
Chitransh Gupta
Chitransh Gupta

Posted on • Originally published at cheeto.hashnode.dev

Email Is Infrastructure. Start Treating It Like One.

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

The OTel packages required for tracing and metrics are already included in package.json and will be installed automatically.

What OTel packages are installed?
  • @opentelemetry/api - the core OpenTelemetry API for creating spans and metrics
  • @opentelemetry/auto-instrumentations-node - automatically instruments HTTP, Express and other libraries without any code changes. This package also pulls in the Node.js SDK and OTLP exporters as dependencies, so you don't need to install them separately.

The sample app (sample-app.js) has three endpoints:

  • POST /register → sends a welcome email to a real address

  • POST /register/bounce → triggers a bounce scenario

  • POST /register/spam → triggers a spam complaint scenario

Copy .env.example to .env

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

And add your Resend API key (starts with re_) and sender email.

RESEND_API_KEY=your_api_key
SENDER_EMAIL=you@yourdomain.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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.

Welcome email from Sample App

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

https://your-id.ngrok-free.app is your forwarding URL. Append /webhook to it:

https://your-id.ngrok-free.app/webhook
Enter fullscreen mode Exit fullscreen mode

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.

Webhook configuration in Rese

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
Enter fullscreen mode Exit fullscreen mode

Click to know details about the OTEL environment variables

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.

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
Enter fullscreen mode Exit fullscreen mode

Triggering email events

The sample application has three endpoints, each simulating a different email delivery outcome.

  • /register → triggers email.sent and email.delivered events

  • /register/bounce → triggers email.sent and email.bounced events

  • /register/spam → triggers email.sent and email.complained events

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"}'
Enter fullscreen mode Exit fullscreen mode

Replace your@email.com with your own email address in the first command.

You should see webhook events arriving in your terminal:

Webhook Events Terminal Output

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.

Webhook events as trace in SigNoz

Click on any trace to see the full span details. Here is what a resend.email.bounced span looks like:
Span detail of bounced email event

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 = Permanent to find all hard bounces

  • Filter by email.domain to see which domains are causing issues

  • Filter by email.id to 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:

Ready to use Email Observability Dashboard.

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.

Jumping from metrics to spans

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
Enter fullscreen mode Exit fullscreen mode

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)