Analytics

Query analytics with the API

The Team API has one analytics endpoint. You send a query as JSON, and you get the numbers in one response.

Code
POST https://api.lettermint.co/v1/analytics

Before you start

You need:

  • A team on the Pro plan.
  • A Team API token with the read:analytics ability.

A Team API token can read the analytics of every project in your team. Keep the token on your server. Do not put it in a browser or a mobile app.

Send your first query

The only required field is metrics. This query returns the delivered recipients, the bounced recipients, and the bounce rate of the last 30 days.

The examples use version 3 of the Lettermint SDKs. See SDKs to install one. Without an SDK, send the same fields as JSON, as the cURL example shows.

import { Lettermint } from "lettermint"; const lettermint = new Lettermint({ teamToken: process.env.LETTERMINT_TEAM_TOKEN!, }); const result = await lettermint.analytics({ metrics: ["delivered", "bounced", "bounce_rate"], }); console.log(result.data.summary?.metrics);

Response:

JSONCode
{ "data": { "summary": { "metrics": { "delivered": 48210, "bounced": 312, "bounce_rate": 0.006423586090465504 }, "rate_bases": { "bounce_rate": { "numerator": 312, "denominator": 48571 } } } }, "meta": { "time_basis": "event", "timezone": "UTC", "interval": "day", "from": "2026-10-13T00:00:00.000000Z", "to": "2026-11-12T00:00:00.000000Z", "effective_to": "2026-11-11T09:41:07.512000Z", "alignment": "hour", "generated_at": "2026-11-11T09:41:07.512000Z", "available_since": "2026-10-12T00:00:00.000000Z", "partial": false, "ongoing": true, "collection_completeness": "best_effort", "last_ingested_at": "2026-11-11T09:40:31.000000Z", "metric_definition_version": "analytics-v2", "ranked_group_limit": 1000 }, "pagination": { "total_groups": 0, "returned_groups": 0, "next_cursor": null, "truncated": false } }

The bounce rate is a fraction. 0.0064 is 0.64 percent. rate_bases shows the two counts behind the rate: 312 bounces out of 48,571 recipients with a final result.

The fields of a query

FieldPurposeDefaultLimits
metricsThe metrics to returnRequired1 to 100
from, toThe time windowThe last 30 days, with todaySend both or none. 90 days at most.
timezoneThe time zone for dates and bucketsUTCAn IANA name, such as Europe/Amsterdam
includeThe sections of the result["summary"]summary, time_series, breakdown
intervalThe size of a time-series bucketdayhour or day
group_byThe dimensions of the breakdownNone3 at most
filtersConditions that limit the queryNone20 at most, with 100 values each
compareA comparison with an earlier periodNoneprevious_period
include_trendA time series for each row of the breakdownfalseNeeds a breakdown
sortThe order of the breakdown rowsThe first metric, highest firstA metric from metrics
limitThe number of breakdown rows in one page501 to 200
cursorThe next page of a breakdownNoneValid for 60 seconds

The API rejects a field that is not in this table.

Write a query with an SDK

Each SDK has an analytics method. It takes the fields of the table above and returns the response as typed data.

The rest of this page shows each query as JSON, because the fields are the same in every language. This example shows one query with a window, a breakdown, a filter, a sort order, and a comparison in each SDK.

import { Lettermint } from "lettermint"; const lettermint = new Lettermint({ teamToken: process.env.LETTERMINT_TEAM_TOKEN!, }); const result = await lettermint.analytics({ metrics: ["delivery_attempts", "delivered", "deferral_rate"], from: "2026-11-04", to: "2026-11-10", timezone: "Europe/Amsterdam", include: ["summary", "breakdown"], group_by: ["provider_family"], filters: [ { dimension: "message_type", operator: "eq", values: ["transactional"] }, ], sort: { metric: "deferral_rate", direction: "desc" }, compare: "previous_period", limit: 10, }); for (const row of result.data.breakdown ?? []) { console.log(row.dimensions.provider_family, row.metrics.deferral_rate); }

The SDKs differ in how you write the query and read the result.

SDKQueryResult
Node.jsAn object with the JSON field names. The type is AnalyticsQuery.result.data.summary?.metrics.delivered
PHPAn array with the JSON field names.$result->data->summary->metrics->delivered
PythonA dict with the JSON field names. The type is AnalyticsQuery.result["data"]["summary"]["metrics"]["delivered"]
GoThe struct AnalyticsQuery. Metrics, sections, and operators are constants, such as AnalyticsMetricDelivered. Wrap an optional field in lettermint.Ptr.result.Data.Summary.Metrics.Delivered
JavaThe builder AnalyticsQuery.builder(). Metrics, sections, and operators are constants, such as AnalyticsMetric.DELIVERED.result.data().summary().metrics().delivered()

Two details apply to Go and Java:

  • A tag dimension has no constant. In Go, write lettermint.AnalyticsGroupDimension("tag:campaign"). In Java, write AnalyticsGroupDimension.of("tag:campaign"). For a filter, use AnalyticsDimension in the same way.
  • A metric can have no value. In Java, the accessor returns null. In Go, a metric is a Nullable value, and its Get method returns the value and false when there is no value.

A section that you did not ask for in include is absent from the result. In Go, result.Data.Summary is then nil.

Set the time window

You can give the window as two dates or as two instants. Do not mix the two forms.

Dates. Use YYYY-MM-DD. Both dates are included. Lettermint reads the dates in the time zone of the query.

JSONCode
{ "metrics": ["delivered"], "from": "2026-11-01", "to": "2026-11-07", "timezone": "Europe/Amsterdam" }

This window starts on 1 November at 00:00 Amsterdam time. It ends on 8 November at 00:00.

Instants. Use RFC 3339 with an offset, such as 2026-11-01T08:00:00Z. The to instant is not included.

A window starts and ends at a whole UTC hour. If an instant is not at a whole hour, Lettermint moves from back and to forward to the nearest whole UTC hour. meta.from and meta.to show the window that Lettermint used.

A window that includes the present time is correct, but not finished. meta.ongoing is true, and meta.effective_to shows where the data ends.

Add a time series

Add time_series to include to get one row for each bucket.

JSONCode
{ "metrics": ["delivered", "deferral_rate"], "from": "2026-11-10", "to": "2026-11-10", "timezone": "Europe/Amsterdam", "include": ["summary", "time_series"], "interval": "hour" }
IntervalBucketLongest window
hourOne hour31 days, which is 744 buckets
dayOne calendar day in the time zone of the query90 days

Each bucket has this form:

JSONCode
{ "from": "2026-11-10T09:00:00+01:00", "to": "2026-11-10T10:00:00+01:00", "available": true, "partial": false, "metrics": { "delivered": 1874, "deferral_rate": 0.0211864406779661 }, "rate_bases": { "deferral_rate": { "numerator": 40, "denominator": 1888 } } }
FieldMeaning
availablefalse when Lettermint has no data for the bucket. The metrics are then null. This applies to a bucket in the future and to a bucket before meta.available_since.
partialtrue when the bucket covers less than its full length. An example is the current hour.

The series has a row for each bucket, also when nothing happened. A bucket without events has the count 0.

Time zones

Daily buckets follow the calendar days of the time zone. A day with a change to or from daylight saving time has 23 or 25 hours.

Some time zones have an offset that is not a whole hour, such as Asia/Kolkata. In these zones, an hourly bucket starts at 30 minutes past the local hour. The first and the last daily bucket can be partial.

Break down the results

A breakdown splits each metric by the values of one or more dimensions. Add breakdown to include, and put the dimensions in group_by. You must send both.

JSONCode
{ "metrics": ["delivery_attempts", "delivered", "deferral_rate"], "from": "2026-11-04", "to": "2026-11-10", "include": ["summary", "breakdown"], "group_by": ["provider_family"], "sort": { "metric": "deferral_rate", "direction": "desc" }, "limit": 5 }

Each row has the values of its dimensions and the metrics for that group:

JSONCode
{ "dimensions": { "provider_family": "Microsoft" }, "metrics": { "delivery_attempts": 9120, "delivered": 8350, "deferral_rate": 0.08333333333333333 }, "rate_bases": { "deferral_rate": { "numerator": 760, "denominator": 9120 } } }

With two or three dimensions, each row is one combination of values. A value of null in dimensions means that the email has no value for that dimension.

Some dimensions change what a metric counts, and not every dimension works with every metric. See Dimensions.

Sort the rows

sort takes a metric from metrics and a direction, asc or desc. Without sort, Lettermint orders the rows by the first metric, highest first. Rows where the metric is null come last.

Get more rows

pagination tells you how many groups there are.

FieldMeaning
total_groupsThe number of groups that match the query
returned_groupsThe number of rows in this response
next_cursorA value for the next page, or null when there are no more rows
truncatedtrue when there are more groups than Lettermint ranks

To get the next page, send the same query again and add cursor. Do not change another field. A cursor is valid for 60 seconds. After that time, send the query again without cursor.

Lettermint ranks 1,000 groups at most, in the order of sort. When truncated is true, the breakdown holds the first 1,000 groups only. Add a filter, or sort by the metric that matters to you.

Add a trend to each row

Set include_trend to true to get a time series in each row, in the field trend. The rows of one page multiplied by the number of buckets must stay below 10,000. With compare, the buckets of the two periods both count. Use a lower limit or a shorter window if the API rejects the query.

Filter the query

A filter limits all sections of the result to the email that matches. This query counts the bounces at two providers, for one project:

JSONCode
{ "metrics": ["bounced", "bounce_rate"], "filters": [ { "dimension": "provider_family", "operator": "in", "values": ["Google", "Microsoft"] }, { "dimension": "project_id", "operator": "eq", "values": ["0b8f4a7e-5a52-4d0c-9d0e-6f3c3f2b1a10"] } ] }

A query combines its filters with AND. See Filters for the operators.

Compare two periods

Set compare to previous_period. Lettermint runs the query a second time for the period of the same length that ends where your window starts.

JSONCode
{ "metrics": ["delivered", "bounce_rate"], "from": "2026-11-04", "to": "2026-11-10", "compare": "previous_period" }

Here the window has seven days, so the previous period is 28 October to 3 November. The summary, each bucket, and each breakdown row get two more fields:

JSONCode
{ "metrics": { "delivered": 48210, "bounce_rate": 0.006423586090465504 }, "rate_bases": { "bounce_rate": { "numerator": 312, "denominator": 48571 } }, "previous": { "metrics": { "delivered": 46680, "bounce_rate": 0.006168637794605632 }, "rate_bases": { "bounce_rate": { "numerator": 290, "denominator": 47012 } } }, "change": { "delivered": { "absolute": 1530, "relative": 0.032776349614395885 }, "bounce_rate": { "absolute": 0.0002549482958598718, "relative": 0.041329756155049295, "percentage_points": 0.02549482958598718 } } }
FieldMeaning
absoluteThe current value minus the previous value
relativeThe change as a fraction of the previous value. 0.03 is an increase of 3 percent.
percentage_pointsFor rates only. The difference between the two rates in percentage points.

A change is null when Lettermint cannot compare the two periods fairly:

  • One of the periods starts before meta.available_since.
  • One of the two values is null.
  • The previous value is 0. Only relative is null in this case.

When your window is not finished, Lettermint compares the same part of the previous period. On Wednesday at 14:00, a query for this week is compared with last week until Wednesday at 14:00.

meta.comparison shows the previous period that Lettermint used.

Read the metadata

meta describes the data behind the numbers.

FieldMeaning
from, toThe window that Lettermint used
effective_toThe end of the data. For a finished window, this is equal to to.
timezone, intervalThe time zone and the bucket size of the query
alignmenthour or day. The unit that Lettermint moved the window limits to. day applies to queries with from_address or subject.
ongoingtrue when the window ends in the future
partialtrue when a part of the window has no data, because it starts before available_since
available_sinceThe start of the analytics history that you can query
generated_atThe time at which Lettermint ran the query
last_ingested_atThe time of the newest data that the query read. null when the query matched no data.
ranked_group_limitThe largest number of groups that this query can rank
comparisonThe previous period. Present only with compare.

Null values

null and 0 are different. 0 means that Lettermint counted and found nothing. null means that Lettermint cannot give a value.

A metric is null whenExample
The bucket has no dataA bucket in the future, or before available_since
The rate has a denominator of zerobounce_rate in an hour without a final delivery result
The latency has no samplesdelivery_latency_p95_ms in an hour without deliveries
The metric has no value for the dimensions of the queryaccepted with a filter on provider_family

Limits

LimitValue
Queries for each team60 in one minute, and 2 at the same time
Time for one query10 seconds
Window90 days
Hourly buckets744
Dimensions in group_by3
Filters20, with 100 values each
Rows in one page200
Groups that Lettermint ranks1,000
Rows multiplied by buckets, with include_trend10,000
Lifetime of a cursor60 seconds

The API and the MCP server share the query limits of a team. A request for the next page counts as one query.

Errors

StatusCauseWhat to do
400The body is not valid JSON.Correct the body.
401The token is missing or not valid.Check the Authorization header.
403The team is not on the Pro plan, the token does not have read:analytics, or the query uses personal data without read:messages.Read the message. It names the cause.
415The Content-Type header is not application/json.Send JSON.
422The query is not valid.Read errors. It names the field.
429The team sent too many queries.Wait for the number of seconds in the Retry-After header.
503Analytics are not available at this moment.Try again later.
504The query needed more than 10 seconds.Use a shorter window, fewer dimensions, or fewer rows.

A 422 response names the problem:

JSONCode
{ "message": "The metric human_opens cannot be combined with dimension tag:campaign.", "errors": { "metrics": ["The metric human_opens cannot be combined with dimension tag:campaign."] } }

A query that reached the time limit will probably reach it again. Change the query before you send it again.

Next steps