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?
Section titled “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
Section titled “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 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.
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": "[email protected]" }], "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.
Capture engagement events with webhooks
Section titled “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.
import jsonfrom fastapi import BackgroundTasks, FastAPI, HTTPException, Requestimport psycopg
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 and Verify webhook signatures.
Aggregate opens, clicks, and replies per campaign
Section titled “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.
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 bouncesFROM email_eventsGROUP BY labelORDER 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
Section titled “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.
What’s next
Section titled “What’s next”- Track email opens and replies for the per-message tracking detail
- Handle bounced email to keep bounce rates off your dashboard
- Receive real-time webhooks for webhook setup and retries
- Verify webhook signatures to keep injected events out of your metrics
- Webhook notifications for the full list of triggers and payloads