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
Before you start
You need:
- A team on the Pro plan.
- A Team API token with the
read:analyticsability.
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.
Response:
Code
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
| Field | Purpose | Default | Limits |
|---|---|---|---|
metrics | The metrics to return | Required | 1 to 100 |
from, to | The time window | The last 30 days, with today | Send both or none. 90 days at most. |
timezone | The time zone for dates and buckets | UTC | An IANA name, such as Europe/Amsterdam |
include | The sections of the result | ["summary"] | summary, time_series, breakdown |
interval | The size of a time-series bucket | day | hour or day |
group_by | The dimensions of the breakdown | None | 3 at most |
filters | Conditions that limit the query | None | 20 at most, with 100 values each |
compare | A comparison with an earlier period | None | previous_period |
include_trend | A time series for each row of the breakdown | false | Needs a breakdown |
sort | The order of the breakdown rows | The first metric, highest first | A metric from metrics |
limit | The number of breakdown rows in one page | 50 | 1 to 200 |
cursor | The next page of a breakdown | None | Valid 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.
The SDKs differ in how you write the query and read the result.
| SDK | Query | Result |
|---|---|---|
| Node.js | An object with the JSON field names. The type is AnalyticsQuery. | result.data.summary?.metrics.delivered |
| PHP | An array with the JSON field names. | $result->data->summary->metrics->delivered |
| Python | A dict with the JSON field names. The type is AnalyticsQuery. | result["data"]["summary"]["metrics"]["delivered"] |
| Go | The struct AnalyticsQuery. Metrics, sections, and operators are constants, such as AnalyticsMetricDelivered. Wrap an optional field in lettermint.Ptr. | result.Data.Summary.Metrics.Delivered |
| Java | The 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, writeAnalyticsGroupDimension.of("tag:campaign"). For a filter, useAnalyticsDimensionin the same way. - A metric can have no value. In Java, the accessor returns
null. In Go, a metric is aNullablevalue, and itsGetmethod returns the value andfalsewhen 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.
Code
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.
Code
| Interval | Bucket | Longest window |
|---|---|---|
hour | One hour | 31 days, which is 744 buckets |
day | One calendar day in the time zone of the query | 90 days |
Each bucket has this form:
Code
| Field | Meaning |
|---|---|
available | false 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. |
partial | true 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.
Code
Each row has the values of its dimensions and the metrics for that group:
Code
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.
| Field | Meaning |
|---|---|
total_groups | The number of groups that match the query |
returned_groups | The number of rows in this response |
next_cursor | A value for the next page, or null when there are no more rows |
truncated | true 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:
Code
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.
Code
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:
Code
| Field | Meaning |
|---|---|
absolute | The current value minus the previous value |
relative | The change as a fraction of the previous value. 0.03 is an increase of 3 percent. |
percentage_points | For 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. Onlyrelativeisnullin 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.
| Field | Meaning |
|---|---|
from, to | The window that Lettermint used |
effective_to | The end of the data. For a finished window, this is equal to to. |
timezone, interval | The time zone and the bucket size of the query |
alignment | hour or day. The unit that Lettermint moved the window limits to. day applies to queries with from_address or subject. |
ongoing | true when the window ends in the future |
partial | true when a part of the window has no data, because it starts before available_since |
available_since | The start of the analytics history that you can query |
generated_at | The time at which Lettermint ran the query |
last_ingested_at | The time of the newest data that the query read. null when the query matched no data. |
ranked_group_limit | The largest number of groups that this query can rank |
comparison | The 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 when | Example |
|---|---|
| The bucket has no data | A bucket in the future, or before available_since |
| The rate has a denominator of zero | bounce_rate in an hour without a final delivery result |
| The latency has no samples | delivery_latency_p95_ms in an hour without deliveries |
| The metric has no value for the dimensions of the query | accepted with a filter on provider_family |
Limits
| Limit | Value |
|---|---|
| Queries for each team | 60 in one minute, and 2 at the same time |
| Time for one query | 10 seconds |
| Window | 90 days |
| Hourly buckets | 744 |
Dimensions in group_by | 3 |
| Filters | 20, with 100 values each |
| Rows in one page | 200 |
| Groups that Lettermint ranks | 1,000 |
Rows multiplied by buckets, with include_trend | 10,000 |
| Lifetime of a cursor | 60 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
| Status | Cause | What to do |
|---|---|---|
400 | The body is not valid JSON. | Correct the body. |
401 | The token is missing or not valid. | Check the Authorization header. |
403 | The 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. |
415 | The Content-Type header is not application/json. | Send JSON. |
422 | The query is not valid. | Read errors. It names the field. |
429 | The team sent too many queries. | Wait for the number of seconds in the Retry-After header. |
503 | Analytics are not available at this moment. | Try again later. |
504 | The query needed more than 10 seconds. | Use a shorter window, fewer dimensions, or fewer rows. |
A 422 response names the problem:
Code
A query that reached the time limit will probably reach it again. Change the query before you send it again.
Next steps
- Examples. Copy complete queries for common questions.
- Metrics and Dimensions. Look up a name or a rule.
- API reference. See the full schema of the request and the response.