# Build an email analytics dashboard

Source: https://developer.nylas.com/docs/cookbook/email/email-analytics-dashboard/

A sales team wants one screen that answers three questions: how many messages went out this week, how many got opened, and who replied. Building that from raw provider data means stitching together Gmail's and Microsoft Graph's separate reporting models, neither of which reports opens or clicks at all. You end up running tracking pixels and link redirects yourself.

This recipe shows how to collect the underlying engagement events from one source and turn them into dashboard metrics. You enable tracking at send time, receive an event each time a recipient acts, and aggregate those events per campaign.

## What metrics belong on an email analytics dashboard?

An email analytics dashboard reports five engagement metrics: send volume, open rate, click rate, reply rate, and bounce rate. Four of them come from a webhook the Email API fires when a recipient acts, so you store the raw events and compute the rates as ratios. Send volume is the exception: you log it yourself when you call the Send API, since no webhook reports an immediate send.

The table maps each metric to its source. Open and click rates typically sit between 15% and 30% for cold outreach, so a dashboard that shows 0% usually means tracking was never enabled on the send, not that nobody read the message.

| Metric | Source | Computed as |
| --- | --- | --- |
| Send volume | Your own send log | Count of messages you sent |
| Open rate | `message.opened` | Messages opened ÷ sent |
| Click rate | `message.link_clicked` | Messages clicked ÷ sent |
| Reply rate | `thread.replied` | Replies ÷ sent |
| Bounce rate | `message.bounce_detected` | Bounces ÷ sent |

## Turn on tracking when you send

Engagement events only fire for messages sent with tracking enabled, so the dashboard starts at send time. Pass a `tracking_options` object on the [Send Message](/docs/reference/api/messages/send-message/) request with the booleans `opens`, `links`, and `thread_replies`, plus a `label` that groups sends into a campaign. That label is echoed in every open, click, and reply notification, which is how you attribute an open 3 days later back to the right campaign.

The request below sends one tracked message tagged to a campaign. Tracking requires a production application, so trial accounts return `Tracking options are not allowed for trial accounts`. The label is free text, so reuse the same string across every send in a sequence. Log a `sent` row in your events table when this call returns, because that's how the dashboard counts send volume.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "subject": "Following up on your trial",
    "body": "Thanks for trying us out. <a href=\"https://example.com/demo\">Book a demo</a> when ready.",
    "to": [{ "name": "Kim Townsend", "email": "kim@example.com" }],
    "tracking_options": {
      "opens": true,
      "links": true,
      "thread_replies": true,
      "label": "q3-trial-followup"
    }
  }'
```

For the per-recipient mechanics of opens and replies, see [Track email opens and replies](/docs/cookbook/use-cases/build/track-email-opens/).

## Capture engagement events with webhooks

The dashboard's data layer is a webhook endpoint that receives every engagement event and writes it to your database. Create one webhook subscription for the four engagement triggers and the API delivers a JSON payload within a few seconds of each open, click, or reply. Verify the `X-Nylas-Signature` header on every request so a third party can't inject fake opens into your numbers.

The handler below verifies the signature, acknowledges with a `200` inside the 10 second window, then records each event in a background task so a slow database never triggers a webhook retry. Open, click, and reply events carry the `label` and a `message_id` directly on `data.object`; a bounce carries neither, so the handler reads the original message id from the `origin` object and recovers the label from the matching `sent` row. Store the raw event rather than incrementing a counter, because a single message fires `message.opened` more than once and you need the rows to deduplicate by message later. A busy outreach account can send 2,000 messages a day, so index the table on `label` and `trigger`.

```python

from fastapi import BackgroundTasks, FastAPI, HTTPException, Request


app = FastAPI()
TRACKED = {
    "message.opened", "message.link_clicked",
    "thread.replied", "message.bounce_detected",
}

@app.post("/webhooks/nylas")
async def nylas_webhook(request: Request, background_tasks: BackgroundTasks):
    raw = await request.body()
    # Reject forged events before trusting the payload (see Verify webhook signatures).
    if not verify_nylas_signature(raw, request.headers.get("X-Nylas-Signature")):
        raise HTTPException(status_code=401)
    payload = json.loads(raw)
    if payload["type"] in TRACKED:
        # Acknowledge first, then write off the request path so a slow DB never trips a retry.
        background_tasks.add_task(record_event, payload)
    return {"ok": True}

def record_event(payload):
    trigger = payload["type"]
    obj = payload["data"]["object"]
    # Engagement events carry message_id directly; a bounce nests the original under origin.
    message_id = obj.get("message_id") or obj.get("origin", {}).get("id")
    with psycopg.connect("dbname=analytics") as conn:
        label = obj.get("label")  # bounces carry no label
        if not label and message_id:
            row = conn.execute(
                "SELECT label FROM email_events "
                "WHERE message_id = %s AND trigger = 'sent' LIMIT 1",
                (message_id,),
            ).fetchone()
            label = row[0] if row else None
        conn.execute(
            "INSERT INTO email_events (trigger, label, message_id, ts) "
            "VALUES (%s, %s, %s, now())",
            (trigger, label or "untagged", message_id),
        )
```

Webhook delivery and the signature check are covered in [Receive real-time webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) and [Verify webhook signatures](/docs/cookbook/use-cases/build/verify-webhook-signatures/).

## Aggregate opens, clicks, and replies per campaign

The dashboard reads from the events table, not from the live API, so a campaign view is a single grouped query. Group rows by the `label` you set at send time, then divide each engagement count by the number of sent messages to get a rate. Deduplicate opens by `message_id` first, because a single message with images cached on Gmail can fire `message.opened` 6 times in 60 seconds.

The query below produces one row per campaign with all five headline numbers. Counting `DISTINCT message_id` is what separates a real open rate from raw pixel hits, since the `recents` array in each event reports every individual open. The `sent` count comes from the rows you logged when the Send API returned.

```sql
SELECT
  label,
  COUNT(*) FILTER (WHERE trigger = 'sent')                                    AS sent,
  COUNT(DISTINCT message_id) FILTER (WHERE trigger = 'message.opened')        AS opens,
  COUNT(DISTINCT message_id) FILTER (WHERE trigger = 'message.link_clicked')  AS clicks,
  COUNT(*) FILTER (WHERE trigger = 'thread.replied')                          AS replies,
  COUNT(*) FILTER (WHERE trigger = 'message.bounce_detected')                 AS bounces
FROM email_events
GROUP BY label
ORDER BY sent DESC;
```

Render the result as a table or a time series. Open rate is `opens / sent`, and most teams track it as a 7-day rolling average to smooth out send-day spikes.

## Things to know about email analytics

Open tracking works with a 1x1 pixel, which has two real limits worth designing around. Apple's Mail Privacy Protection pre-fetches that pixel on Apple Mail, so opens from Apple devices register whether or not a human read the message. Treat opens as a soft signal and weight replies and clicks higher, since those require a deliberate action.

Click tracking rewrites links to pass through a redirect. The `message.link_clicked` event lists the clicked URLs in its `link_data` dictionary and carries a `recents` entry per click, so one delivery can report several clicks. Some corporate security scanners follow every link in an inbound message, which inflates clicks the same way Apple inflates opens. Filtering clicks that land within 2 seconds of delivery removes most scanner noise.

Bounces tell you about list quality, not engagement. A `message.bounce_detected` event means the address rejected the message, and a bounce rate above 2% on cold lists will hurt your sender reputation with the receiving provider. Feed bounces into a suppression list as described in [Handle bounced email](/docs/cookbook/use-cases/build/handle-bounced-email/).

## What's next

- [Track email opens and replies](/docs/cookbook/use-cases/build/track-email-opens/) for the per-message tracking detail
- [Handle bounced email](/docs/cookbook/use-cases/build/handle-bounced-email/) to keep bounce rates off your dashboard
- [Receive real-time webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) for webhook setup and retries
- [Verify webhook signatures](/docs/cookbook/use-cases/build/verify-webhook-signatures/) to keep injected events out of your metrics
- [Webhook notifications](/docs/v3/notifications/) for the full list of triggers and payloads