HubSpot is a leading platform for inbound marketing, sales, and customer relationship management. With HubSpot, you can get exhaustive data on your marketing efforts.
But that same data can be exhausting, whether due to the sheer volume of information you have to wade through or due to limitations in HubSpot’s native reporting.
If you’re a SQL lover—or just someone who’s frustrated by not being able to break out your email performance by day of week or time of day—we’re here to help. We’ll walk you through HubSpot’s email metrics, then we’ll give you the SQL you need to evaluate your email campaigns.
Check out our first entry in this series: HubSpot & SQL: A New Way to Understand Your Revenue. Want to skip to the SQL? Click one of the links below:
Table of contents |
The ultimate goal of the metrics in HubSpot email analytics is to visualize the conversion funnel: how users move from a touchpoint with your brand to becoming a successful deal. These metrics help to identify weak places in the funnel and direct your effort into the right path.
In the HubSpot user interface, you can build email campaign performance reports that aggregate results on different levels. The lowest granularity of HubSpot data analytics is a single email newsletter.
Here’s an overview of the metrics you’ll find in HubSpot’s native reporting and how each of them are defined:
The basic thing you need to know about your emails is whether they were opened and by how many recipients. This metric shows the percentage of recipients who successfully received your email and opened it. Failed deliveries are excluded from HubSpot’s open rate.
The click through rate (CTR) shows the percentage of recipients who clicked on an element inside your email. CTR is one of the most important metrics for inbound campaigns because it demonstrates that you actually managed to pull recipients in and that they engaged with your marketing message.
These two dashboards are a bit tricky since they mix up rates from different stages of the email lifecycle. They show—from slightly different perspectives—how many times an email failed to be delivered but also how many people marked it as spam or unsubscribed.
These metrics match the data from one certain email with the data from your CRM. It shows which of your existing contacts actively engaged with the email.
This shows how much interest recipients had in the content of your newsletter. Combined with CTR data, it enables you to recognize effective and poor email content strategy so you can design future campaigns using the “tricks” that work.
For all aforementioned metrics, you can create aggregated reports in HubSpot’s native analytics tool based on a time period, campaign, or for all campaigns.
One major drawback of HubSpot’s email reporting is that not all metrics are available to all users. For example, time spent viewing email and a few aggregated metrics are only available for certain pricing plans.
More importantly, to create meaningful reports that go beyond the surface-level metrics in the HubSpot interface, you have to:
That doesn’t sound like something you want to do regularly or can do quickly and painlessly, does it?
Thankfully, HubSpot offers an API that enables you to fetch data automatically and in its entirety. The API provides metrics you won’t get through the user interface and makes it easier to manipulate the numbers to better understand your email campaigns.
With this data, you first see how unique HubSpot is in how it treats each sent email. The API data delivers “event types” that correspond to different stages in the email lifecycle. There are 4 stages and 12 event types. Stages and event types are not exactly matched with the lifecycle of a campaign or send, they are rather centered around every single email.
That’s a ton of detail, but the question is still how to make sense out of this data. That’s where SQL queries come in.
We have a lot of arguments for using SQL since we love this query language. But there are a few that are particularly helpful in this context:
You may be asking yourself by now, what do I do if I am not familiar with either SQL or the HubSpot metrics, or both? No worries! We built and tested a few must-have SQL queries for HubSpot that every email marketer needs. If you connect your HubSpot data to Panoply, all you have to do is copy and paste the query into the workbench for easy insights.
We elaborated on a few KPIs that correspond with the conversion funnel and the most insightful aggregation levels for them.
We'll start with something relatively easy: organizing your email data into a conversion funnel. We've chosen to view the funnel on a periodic basis—in this case weekly—to compare conversion over different periods, but you could substitute campaign or subject lines for different insights.
NOTE: For this first query, we'll walk you through what's going on by adding comments in gray throughout the query and we'll include a data table so you have a good sense of what's going on.
---Email Funnel
---This section counts the number of emails associated with an event/funnel stage with kpis as ( select (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date as email_sent_date, count(distinct (case when "type" = 'SENT' then id else null end)) as emails_sent, count(distinct (case when "type" = 'DROPPED' then id else null end)) as emails_dropped, count(distinct (case when "type" = 'DELIVERED' then id else null end)) as emails_delivered, count(distinct (case when "type" = 'DEFERRED' then id else null end)) as emails_deferred, count(distinct (case when "type" = 'BOUNCE' then id else null end)) as emails_bounced, count(distinct (case when "type" = 'OPEN' then id else null end)) as emails_opened, count(distinct (case when "type" = 'CLICK' then id else null end)) as emails_clicked, count(distinct (case when "type" = 'STATUSCHANGE' then id else null end)) as emails_statuschanged, count(distinct (case when "type" = 'SPAMREPORT' then id else null end)) as emails_spamreported from "hubspot_email events" where (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date >= dateadd('day', -30, getdate()) group by 1 )
---This section aggregates the total number of emails associated with each event/funnel stage select date_trunc('w', email_sent_date)::date as week_date, sum(emails_sent) as emails_sent, sum(emails_delivered) as emails_delivered, sum(emails_opened) as emails_opened, sum(emails_clicked) as emails_clicked from kpis group by 1 order by 1
Running this query results in a data table that looks like this:
week_date | emails_sent | emails_delivered | emails_opened | emails_clicked |
2021-04-26 | 72,200 | 72,171 | 14,434 | 1,443 |
2021-05-03 | 67,700 | 67,680 | 27,072 | 4,332 |
2021-05-10 | 71,900 | 71,864 | 17,966 | 2,156 |
2021-05-17 | 70,000 | 69,804 | 24,431 | 1,955 |
And voila—you now have a high-level sense of how your emails have been doing over the last month! It's clear that your sent-to-delivered ratio is pretty solid (aka, HubSpot isn't refusing to deliver your emails). However, while both your open rate and click through rate are pretty good, the performance is inconsistent over this period, so it may be worth digging into your segmentation, subject lines, and offers to find out what's working and what's not.
Now that you have a sense of how you can use SQL to understand your HubSpot email campaigns, let's look at some more useful queries. We won't go into as much detail about the data that'll be returned, but remember that it's super easy to start a trial of Panoply—there's no credit card required—and run these queries on your own data.
This simple query aggregates different KPIs by the day of the week. By default, the HubSpot API does not provide these KPIs as separate metrics. Instead, it has a column for all types of events and you’d have to count how often each type of event happened and group them by date to create these KPIs (or export to Excel and create a pivot table, something that’s easy to do once but obnoxious as a regular workflow). Once you get the counts, you can calculate percentages to visualize rates and make your results easy to read.
---Email KPIs by date with kpis as ( select (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date as email_sent_date, count(distinct (case when "type" = 'SENT' then id else null end)) as emails_sent, count(distinct (case when "type" = 'DROPPED' then id else null end)) as emails_dropped, count(distinct (case when "type" = 'PROCESSED' then id else null end)) as emails_processed, count(distinct (case when "type" = 'DELIVERED' then id else null end)) as emails_delivered, count(distinct (case when "type" = 'DEFERRED' then id else null end)) as emails_deferred, count(distinct (case when "type" = 'BOUNCE' then id else null end)) as emails_bounced, count(distinct (case when "type" = 'OPEN' then id else null end)) as emails_opened, count(distinct (case when "type" = 'CLICK' then id else null end)) as emails_clicked, count(distinct (case when "type" = 'STATUSCHANGE' then id else null end)) as emails_statuschanged, count(distinct (case when "type" = 'SPAMREPORT' then id else null end)) as emails_spamreported from "hubspot_email events" where (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date >= dateadd('day', -30, getdate()) group by 1 ) select email_sent_date, sum(emails_sent) as emails_sent, sum(emails_dropped) as emails_dropped, sum(emails_processed) as emails_processed, sum(emails_delivered) as emails_delivered, sum(emails_deferred) as emails_deferred, sum(emails_bounce) as emails_bounced, sum(emails_open) as emails_opened, sum(emails_click) as emails_clicked, sum(emails_statuschange) as emails_statuschanged, sum(emails_spamreport) as emails_spamreported -- kpis as % emails sent, (sum(emails_dropped::DECIMAL) / sum(emails_sent)) as dropped_rate, (sum(emails_processed::DECIMAL) / sum(emails_sent)) as processed_rate, (sum(emails_delivered::DECIMAL) / sum(emails_sent)) as delivered_rate -- kpis as % emails delivered, (sum(emails_deferred::DECIMAL) / sum(emails_delivered)) as deferred_rate, (sum(emails_bounce::DECIMAL) / sum(emails_delivered)) as bounce_rate -- kpis as % emails opened, (sum(emails_click::DECIMAL) / sum(emails_open)) as click_rate, (sum(emails_spamreport::DECIMAL) / sum(emails_open)) as spamreport_rate, (sum(emails_open::DECIMAL) / sum(emails_sent)) as open_rate from kpis group by 1 order by 1
Adding the campaign name to the GROUP
clause makes your report more detailed and shows performance by campaign.
---Email KPIs by Date and Campaign Name with kpis as ( select (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date as email_sent_date, hci.name as campaign_name, count(distinct (case when h.type = 'SENT' then h.id else null end)) as emails_sent, count(distinct (case when h.type = 'DROPPED' then h.id else null end)) as emails_dropped, count(distinct (case when h.type = 'PROCESSED' then h.id else null end)) as emails_processed, count(distinct (case when h.type = 'DELIVERED' then h.id else null end)) as emails_delivered, count(distinct (case when h.type = 'DEFERRED' then h.id else null end)) as emails_deferred, count(distinct (case when h.type = 'BOUNCE' then h.id else null end)) as emails_bounced, count(distinct (case when h.type = 'OPEN' then h.id else null end)) as emails_opened, count(distinct (case when h.type = 'CLICK' then h.id else null end)) as emails_clicked, count(distinct (case when h.type = 'STATUSCHANGE' then h.id else null end)) as emails_statuschanged, count(distinct (case when h.type = 'SPAMREPORT' then h.id else null end)) as emails_spamreported from "hubspot_email events" h inner join hubspot_campaigns_id hci on hci.id = h.emailcampaignid where (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date >= dateadd('day', -30, getdate()) group by 1,2 ), kpis_agg as ( select email_sent_date, campaign_name, sum(emails_sent) as emails_sent, sum(emails_dropped) as emails_dropped, sum(emails_processed) as emails_processed, sum(emails_delivered) as emails_delivered, sum(emails_deferred) as emails_deferred, sum(emails_bounced) as emails_bounced, sum(emails_opened) as emails_opened, sum(emails_clicked) as emails_clicked, sum(emails_statuschanged)as emails_statuschanged, sum(emails_spamreported)as emails_spamreported from kpis group by 1,2 ) select email_sent_date, campaign_name, emails_sent, emails_dropped, emails_processed, emails_delivered, emails_deferred, emails_bounced, emails_opened, emails_clicked, emails_statuschanged, emails_spamreported, -- kpis as pct email sent emails_dropped :: decimal / emails_sent as dropped_rate, emails_processed :: decimal / emails_sent as processed_rate, emails_delivered :: decimal / emails_sent as delivered_rate, -- kpis as pct emails delivered emails_deferred :: decimal / emails_delivered as deferred_rate, emails_bounced :: decimal / emails_delivered as bounce_rate, -- kpis as pct emails open emails_clicked :: decimal / emails_opened as click_rate, emails_spamreported :: decimal / emails_opened as spamreport_rate, emails_opened :: decimal / emails_sent as open_rate from kpis_agg where emails_sent > 0 and emails_delivered > 0 and emails_opened > 0
We all know that most people have more free time on weekends than during the week. But whether that’s true for your audience highly depends on your product: if you’re a food delivery, a spa hotel, a streaming platform, or a business-focused tool, the answer is likely to vary.
But by identifying your most performant day of the week, you can set the best time for sending out your newsletters. To do that, we add a step that transforms dates into days of the week.
---Email KPIs by Day of Week (DOW) with kpis as ( select (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date as email_sent_date, count(distinct (case when "type" = 'SENT' then id else null end)) as emails_sent, count(distinct (case when "type" = 'DROPPED' then id else null end)) as emails_dropped, count(distinct (case when "type" = 'PROCESSED' then id else null end)) as emails_processed, count(distinct (case when "type" = 'DELIVERED' then id else null end)) as emails_delivered, count(distinct (case when "type" = 'DEFERRED' then id else null end)) as emails_deferred, count(distinct (case when "type" = 'BOUNCE' then id else null end)) as emails_bounced, count(distinct (case when "type" = 'OPEN' then id else null end)) as emails_opened, count(distinct (case when "type" = 'CLICK' then id else null end)) as emails_clicked, count(distinct (case when "type" = 'STATUSCHANGE' then id else null end)) as emails_statuschanged, count(distinct (case when "type" = 'SPAMREPORT' then id else null end)) as emails_spamreported from "hubspot_email events" where (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date >= dateadd('day', -30, getdate()) group by 1 ) select case when date_part(dow, email_sent_date) = 0 then 'Sunday' when date_part(dow, email_sent_date) = 1 then 'Monday' when date_part(dow, email_sent_date) = 2 then 'Tuesday' when date_part(dow, email_sent_date) = 3 then 'Wednesday' when date_part(dow, email_sent_date) = 4 then 'Thursday' when date_part(dow, email_sent_date) = 5 then 'Friday' when date_part(dow, email_sent_date) = 6 then 'Saturday' else 'unknown' end as day_of_week, sum(emails_sent) as emails_sent, sum(emails_dropped) as emails_dropped, sum(emails_processed) as emails_processed, sum(emails_delivered) as emails_delivered, sum(emails_deferred) as emails_deferred, sum(emails_bounced) as emails_bounced, sum(emails_opened) as emails_opened, sum(emails_clicked) as emails_clicked, sum(emails_statuschanged) as emails_statuschanged, sum(emails_spamreported) as emails_spamreported, -- kpis as % emails sent (sum(emails_dropped::DECIMAL) / sum(emails_sent)) as dropped_rate, (sum(emails_processed::DECIMAL) / sum(emails_sent)) as processed_rate, (sum(emails_delivered::DECIMAL) / sum(emails_sent)) as delivered_rate, -- kpis as % emails delivered (sum(emails_deferred::DECIMAL) / sum(emails_delivered)) as deferred_rate, (sum(emails_bounced::DECIMAL) / sum(emails_delivered)) as bounce_rate, -- kpis as % emails opened (sum(emails_clicked::DECIMAL) / sum(emails_opened)) as click_rate, (sum(emails_spamreported::DECIMAL) / sum(emails_opened)) as spamreport_rate, (sum(emails_opened::DECIMAL) / sum(emails_sent)) as open_rate from kpis group by 1 order by 1
On top of DOW aggregation, you can enhance your report by identifying the hour of the day that gets the most opens and/or clicks.
---Email KPIs by Hour and Day of Week with kpis as ( select (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date as email_sent_date, extract('h' from TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') as hour_of_day, count(distinct (case when "type" = 'SENT' then id else null end)) as emails_sent, count(distinct (case when "type" = 'DROPPED' then id else null end)) as emails_dropped, count(distinct (case when "type" = 'PROCESSED' then id else null end)) as emails_processed, count(distinct (case when "type" = 'DELIVERED' then id else null end)) as emails_delivered, count(distinct (case when "type" = 'DEFERRED' then id else null end)) as emails_deferred, count(distinct (case when "type" = 'BOUNCE' then id else null end)) as emails_bounced, count(distinct (case when "type" = 'OPEN' then id else null end)) as emails_opened, count(distinct (case when "type" = 'CLICK' then id else null end)) as emails_clicked, count(distinct (case when "type" = 'STATUSCHANGE' then id else null end)) as emails_statuschanged, count(distinct (case when "type" = 'SPAMREPORT' then id else null end)) as emails_spamreported from "hubspot_email events" where (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date >= dateadd('day', -30, getdate()) group by 1,2 ) select case when date_part(dow, email_sent_date) = 0 then 'Sunday' when date_part(dow, email_sent_date) = 1 then 'Monday' when date_part(dow, email_sent_date) = 2 then 'Tuesday' when date_part(dow, email_sent_date) = 3 then 'Wednesday' when date_part(dow, email_sent_date) = 4 then 'Thursday' when date_part(dow, email_sent_date) = 5 then 'Friday' when date_part(dow, email_sent_date) = 6 then 'Saturday' else 'unknown' end as day_of_week, hour_of_day, sum(emails_sent) as emails_sent, sum(emails_dropped) as emails_dropped, sum(emails_processed) as emails_processed, sum(emails_delivered) as emails_delivered, sum(emails_deferred) as emails_deferred, sum(emails_bounced) as emails_bounced, sum(emails_opened) as emails_opened, sum(emails_clicked) as emails_clicked, sum(emails_statuschanged) as emails_statuschanged, sum(emails_spamreported) as emails_spamreported, -- kpis as % emails sent (sum(emails_dropped::DECIMAL) / sum(emails_sent)) as dropped_rate, (sum(emails_processed::DECIMAL) / sum(emails_sent)) as processed_rate, (sum(emails_delivered::DECIMAL) / sum(emails_sent)) as delivered_rate, -- kpis as % emails delivered (sum(emails_deferred::DECIMAL) / sum(emails_delivered)) as deferred_rate, (sum(emails_bounced::DECIMAL) / sum(emails_delivered)) as bounce_rate, -- kpis as % emails opened (sum(emails_clicked::DECIMAL) / sum(emails_opened)) as click_rate, (sum(emails_spamreported::DECIMAL) / sum(emails_opened)) as spamreport_rate, (sum(emails_opened::DECIMAL) / sum(emails_sent)) as open_rate from kpis group by 1,2 order by 1,2
Within one campaign, you send multiple newsletters and may be experimenting with the subject lines. Does it work better with a CTA or a general knowledge title? How hard-selling should your subject line be? And how does it influence CTR or unsubscribe rates?
By adding subject line as another grouping in your query, you can dig deeper into your data and answer these three (and even more!) questions.
---Email KPIs by Date, Subject Line and Campaign Name with kpis as ( select (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date as email_sent_date, hci.subject as subject_line, hci.name as campaign_name, count(distinct (case when h.type = 'SENT' then h.id else null end)) as emails_sent, count(distinct (case when h.type = 'DROPPED' then h.id else null end)) as emails_dropped, count(distinct (case when h.type = 'PROCESSED' then h.id else null end)) as emails_processed, count(distinct (case when h.type = 'DELIVERED' then h.id else null end)) as emails_delivered, count(distinct (case when h.type = 'DEFERRED' then h.id else null end)) as emails_deferred, count(distinct (case when h.type = 'BOUNCE' then h.id else null end)) as emails_bounced, count(distinct (case when h.type = 'OPEN' then h.id else null end)) as emails_opened, count(distinct (case when h.type = 'CLICK' then h.id else null end)) as emails_clicked, count(distinct (case when h.type = 'STATUSCHANGE' then h.id else null end)) as emails_statuschanged, count(distinct (case when h.type = 'SPAMREPORT' then h.id else null end)) as emails_spamreported from "hubspot_email events" h inner join hubspot_campaigns_id hci on hci.id = h.emailcampaignid where (TIMESTAMP 'epoch' + created / 1000 * INTERVAL '1 Second ') :: date >= dateadd('day', -30, getdate()) group by 1,2,3 ), kpis_agg as ( select email_sent_date, subject_line, campaign_name, sum(emails_sent) as emails_sent, sum(emails_dropped) as emails_dropped, sum(emails_processed) as emails_processed, sum(emails_delivered) as emails_delivered, sum(emails_deferred) as emails_deferred, sum(emails_bounced) as emails_bounced, sum(emails_opened) as emails_opened, sum(emails_clicked) as emails_clicked, sum(emails_statuschanged) as emails_statuschanged, sum(emails_spamreported) as emails_spamreported from kpis group by 1,2,3 ) select email_sent_date, subject_line, campaign_name, emails_sent, emails_dropped, emails_processed, emails_delivered, emails_deferred, emails_bounced, emails_opened, emails_clicked, emails_statuschanged, emails_spamreported, -- kpis as pct email sent emails_dropped :: decimal / emails_sent as dropped_rate, emails_processed :: decimal / emails_sent as processed_rate, emails_delivered :: decimal / emails_sent as delivered_rate, -- kpis as pct emails delivered emails_deferred :: decimal / emails_delivered as deferred_rate, emails_bounced :: decimal / emails_delivered as bounce_rate, -- kpis as pct emails open emails_clicked :: decimal / emails_opened as click_rate, emails_spamreported :: decimal / emails_opened as spamreport_rate, emails_opened :: decimal / emails_sent as open_rate from kpis_agg where emails_sent > 0 and emails_delivered > 0 and emails_opened > 0
As we’ve shown, SQL is an incredibly powerful tool for getting more detail out of your data. However, to unleash the power of SQL, you will first need to store your HubSpot data in the cloud. But we have a life hack regarding this for you, too.
With Panoply, you can have HubSpot data and all your other data in one place. Along with secure, GDPR-compliant cloud storage, Panoply has a built-in workbench that’s handy for quick exploration. Once your data is synced, you can see all the tables you’ve created from the raw data and query it like a pro using our pre-built queries. And, of course, you can connect a BI tool to turn all those numbers into some seriously powerful insights.
If you’re ready to take your email analytics to the next level, give Panoply a try today!