Reference
Queryable SQL
Query your sending data with the Queryable builder or raw ClickHouse SQL, learn the available tables and limits, and start from example queries.
The Queryable tab of Email APIAnalytics lets you ask your own questions of your sending data, either with a point-and-click builder or with read-only ClickHouse SQL. It’s available on Pay as you goProBusinessCustom. Results show as a chart and a table, and you can save queries for your team.
Builder
The builder writes the query for you and uses the page’s date range, Sending domain and API key filters.
| Control | Options |
|---|---|
| Measure | sends, bounces, complaints, loads, clicks, unique loads, unique clicks, unique emails, failed, rejected, suppressed, avg delivery time, bounce rate, complaint rate |
| Time grain | hour, day, week |
| Group by | none, status, domain, credential (API key), campaign, tag, country |
| Chart type | Timeseries, Pie, Stat, Table |
Not every combination applies. Group by country works with loads and clicks, which have a country; status, domain, credential, campaign and tag work with the email measures (sends, bounces, failed, rejected, suppressed, unique emails and the two rates). Complaints and average delivery time aren’t grouped. Groups show IDs such as dom_… and key_…; use raw SQL to join names. Grouping by tag usually returns a single (none) group.
Select Run to see the result.
Raw SQL
Turn on Raw SQL to write ClickHouse SQL in the editor, then select Run.
- Queries must start with
SELECTorWITHand can only read the tables listed below. Writes, table functions such asurl()ors3(), andsystemtables are blocked. - Every table is automatically limited to your workspace. You don’t need a
workspace_oidcondition. - The page’s date range and filters don’t apply to raw SQL. Add your own time condition, such as
created_at >= now() - INTERVAL 30 DAY, or queries scan all retained data. - Each
FROMandJOINmust name a table directly. Subqueries in parentheses are fine, but don’t put an alias after a table name (FROM emails_raw e) and don’t select from a namedWITHquery. Qualify columns with the table name instead, for examplesending_domains_dim.name. - Queries return at most 5,000 rows and stop after 10 seconds. A query can be up to 20,000 characters.
To chart the result, return a time column named t, a numeric column named v, and optionally a group_key column for one series per value. Any other shape shows as a table. The table on the page shows the first 100 rows.
Saved queries
Type a name in Save as… and select Save query to store the current builder settings or SQL. Saved queries appear as buttons above the results for everyone in the workspace. Select one to load it, or select its × to delete it.
Tables
The _raw tables are kept for up to 365 days. The _daily tables hold daily totals and follow your plan’s analytics retention.
emails_raw
One row per version of each email. Every status change adds a new version, so an email appears several times. Group by oid and use argMax(column, _peerdb_version) to read the latest value, as in the examples below.
| Column | Description |
|---|---|
oid |
Email ID (em_…). |
type |
outgoing for sent mail, inbound for received mail. |
status |
The email status, for example delivered or bounced. |
rcpt_to, mail_from, subject, message_id |
Envelope and subject. |
sending_domain_oid, credential_oid |
Sending domain ID (dom_…) and API key ID (key_…). |
campaign_oid, automation_oid |
Set for campaign and automation sends. |
tag |
Usually empty. |
spam_score, inspected |
Rspamd score, and whether the email was scored. |
size |
Message size in bytes. |
loaded, clicked |
Time of the first open and first click, or NULL. |
created_at, updated_at |
Timestamps in UTC. |
_peerdb_version |
Version number; higher is newer. |
deliveries_raw
One row per delivery record: each attempt, and records such as held or suppressed.
| Column | Description |
|---|---|
oid, email_oid |
Delivery ID and the email it belongs to. |
status |
For example delivered, attempted, bounced, held, suppressed. |
output |
The receiving server’s reply, for example 250 2.0.0 OK. |
details |
Emailit’s description of the result. |
time |
How long the attempt took, the value behind Time to inbox. |
sent_with_ssl |
Whether the connection used TLS. |
timestamp, created_at |
When the attempt happened. |
loads_raw and clicks_raw
One row per open or click.
| Column | Description |
|---|---|
oid, email_oid |
Load or click ID and the email it belongs to. |
link_oid |
clicks_raw only: the clicked link’s ID (link_…). Link URLs aren’t stored in the analytics tables. |
ip_address, country, city, user_agent |
Where the open or click came from. |
timestamp, created_at |
When it happened. |
events_raw
One row per event: oid (evt_…), type, data (the payload as a JSON string; read it with functions such as JSONExtractString(data, 'object', 'id')) and created_at.
Daily tables
| Table | Columns |
|---|---|
events_daily |
day, type, cnt, unique_emails |
deliveries_daily |
day, status, cnt, avg_time |
loads_daily |
day, cnt, unique_emails, countries |
clicks_daily |
day, cnt, unique_emails, unique_links |
Sum cnt with sum(cnt). The other columns are ClickHouse aggregate states: read them with uniqMerge(unique_emails), uniqMerge(unique_links), avgMerge(avg_time) and topKMerge(10)(countries).
Dimension tables
| Table | Columns |
|---|---|
sending_domains_dim |
oid, name, created_at |
campaigns_dim |
oid, name, status, created_at |
workspaces_dim |
oid, name, plan_id, created_at |
Join them to show names instead of IDs.
Example queries
Bounce rate by sending domain per day
SELECT
toDate(latest.first_seen) AS t,
sending_domains_dim.name AS group_key,
countIf(latest.status = 'bounced') / count() AS v
FROM (
SELECT
oid,
min(created_at) AS first_seen,
argMax(status, _peerdb_version) AS status,
argMax(sending_domain_oid, _peerdb_version) AS sending_domain_oid
FROM emails_raw
WHERE type = 'outgoing'
AND created_at >= now() - INTERVAL 30 DAY
GROUP BY oid
) AS latest
LEFT JOIN sending_domains_dim ON sending_domains_dim.oid = latest.sending_domain_oid
GROUP BY t, group_key
ORDER BY t, group_keyChoose the Timeseries chart to get one line per domain.
Top clicked links
SELECT
link_oid,
count() AS clicks,
uniqExact(email_oid) AS emails_clicked
FROM clicks_raw
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY link_oid
ORDER BY clicks DESC
LIMIT 20The analytics tables store the link ID, not the URL. To see a link’s URL, open an email that has the click and check its Clicks tab, or collect link.url from email.clicked webhooks.
Delivery time percentiles
SELECT
toDate(timestamp) AS t,
count() AS deliveries,
quantile(0.5)(toFloat64(time)) AS p50,
quantile(0.95)(toFloat64(time)) AS p95,
quantile(0.99)(toFloat64(time)) AS p99
FROM deliveries_raw
WHERE status = 'delivered'
AND time IS NOT NULL
AND timestamp >= now() - INTERVAL 14 DAY
GROUP BY t
ORDER BY tSends and opens per campaign
SELECT
campaigns_dim.name AS campaign,
count() AS sent,
countIf(latest.loaded IS NOT NULL) AS opened,
countIf(latest.clicked IS NOT NULL) AS clicked
FROM (
SELECT
oid,
argMax(campaign_oid, _peerdb_version) AS campaign_oid,
argMax(loaded, _peerdb_version) AS loaded,
argMax(clicked, _peerdb_version) AS clicked
FROM emails_raw
WHERE type = 'outgoing'
AND created_at >= now() - INTERVAL 90 DAY
GROUP BY oid
) AS latest
INNER JOIN campaigns_dim ON campaigns_dim.oid = latest.campaign_oid
GROUP BY campaign
ORDER BY sent DESCErrors
| Message | Cause |
|---|---|
| Only SELECT queries are allowed | The query doesn’t start with SELECT or WITH. |
| Table “…” is not allowed | A FROM or JOIN names something that isn’t an allowed table, such as a named WITH query. |
| Query must read an allowlisted analytics table | The query has no FROM an allowed table. |
| External tables and table functions are not allowed | The query uses a table function such as url(). |
| Queries cannot target another workspace | A workspace_oid = '…' condition names a different workspace. |
ClickHouse syntax errors, for example from an alias after a table name, are shown as returned by ClickHouse.