Reports
Read the same delivery, geofence and survey figures the dashboard's Reports show, for any date range and with the same filters. Reporting works in Production as soon as you create a token.
The reports endpoints return the figures behind the dashboard's Reports as plain numbers, so you can pull them into a spreadsheet, a BI tool or your data warehouse. There's a summary for the whole workspace and one report each for campaigns, locations, notifications and surveys.
| Endpoint | Returns |
|---|---|
GET /reports/summary | Totals for the range, plus the same counts for each day |
GET /reports/campaigns | One row per campaign |
GET /reports/locations | One row per location |
GET /reports/notifications | One row per notification |
GET /reports/surveys | One row per survey on a campaign |
Before you start#
Every reports endpoint needs a token with the reports:read ability. See Authentication.
Reporting is the one part of the API that works in Production straight away: a Production token with reports:read can read reports before Bubbl turns on the full API for your workspace. See Sandbox and Production.
Each workspace reports on its own data, so a Sandbox token reads your Sandbox's test activity and a Production token reads your live results.
Get the summary#
curl "https://api.bubbl.tech/platform/v1/reports/summary?start=2026-09-01&end=2026-09-30" \
-H "Authorization: Bearer $BUBBL_API_TOKEN"const params = new URLSearchParams({ start: '2026-09-01', end: '2026-09-30' });
const response = await fetch(`https://api.bubbl.tech/platform/v1/reports/summary?${params}`, {
headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` },
});
const { data, meta } = await response.json();
console.log(meta.start, meta.end, data.totals.delivered);import os
import requests
response = requests.get(
"https://api.bubbl.tech/platform/v1/reports/summary",
headers={"Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}"},
params={"start": "2026-09-01", "end": "2026-09-30"},
timeout=30,
)
response.raise_for_status()
body = response.json()
print(body["meta"], body["data"]["totals"]){
"data": {
"totals": {
"delivered": 1840,
"devices_reached": 912,
"geofence_entries": 2310,
"geofence_exits": 2204,
"campaigns": 6,
"active_campaigns": 4,
"registered_devices": 5120
},
"daily": [
{ "date": "2026-09-01", "delivered": 58, "devices_reached": 41, "geofence_entries": 77, "geofence_exits": 74 },
{ "date": "2026-09-02", "delivered": 0, "devices_reached": 0, "geofence_entries": 12, "geofence_exits": 12 }
]
},
"meta": { "start": "2026-09-01", "end": "2026-09-30", "timezone": "Europe/London" }
}| Field | What it counts |
|---|---|
delivered | Notifications delivered to devices in the range. |
devices_reached | Different devices that had at least one delivery. |
geofence_entries, geofence_exits | Times a device entered or left one of your locations. |
campaigns, active_campaigns | Campaigns that match your filters, and how many of them are active now. |
registered_devices | All the devices registered in the workspace now, not only in the range. |
daily has one item for every day in the range, with zeros on quiet days, so you can chart it without filling gaps.
Choose the date range#
| Parameter | Format | Default |
|---|---|---|
start | YYYY-MM-DD | 30 days before end, so the range is 30 days |
end | YYYY-MM-DD | Today |
- Days are counted in your workspace's time zone, which comes back as
meta.timezone. A day runs from 00:00 to 23:59 there, andendincludes the whole of its day. endmust be on or afterstart, and at most 366 days after it.- Every response has the range it covers, after defaults, under
meta.startandmeta.end. Store those with the figures rather than the dates you asked for.
Filter a report#
Every report takes the same filters as the filter bar in the dashboard's Reports. Leave a filter out to include everything.
| Parameter | Value | Narrows to |
|---|---|---|
campaign | A campaign id | That campaign's deliveries, and geofence activity at its locations |
location | A location id | That location, and the campaigns that target it |
location_group | A location group id | The group's locations, and the campaigns that target the group |
activation | on_enter, on_exit or fixed_time | Notifications sent on entering a location, on leaving one, or at a scheduled time. on_enter counts only entries and on_exit only exits. |
campaign_status | active, scheduled, paused or ended | Campaigns with that status now |
content_type | message or survey | Notifications of that type |
An id from your other workspace, or one that doesn't exist, isn't an error: it matches nothing, so the figures are zero.
Report on campaigns, locations, notifications and surveys#
The four row reports return a list under data, with the range under meta. They take the filters above, plus status to keep only rows with that status:
curl "https://api.bubbl.tech/platform/v1/reports/campaigns?status=active&start=2026-09-01&end=2026-09-30" \
-H "Authorization: Bearer $BUBBL_API_TOKEN"const params = new URLSearchParams({ status: 'active', start: '2026-09-01', end: '2026-09-30' });
const response = await fetch(`https://api.bubbl.tech/platform/v1/reports/campaigns?${params}`, {
headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` },
});
const { data } = await response.json();
for (const row of data) {
console.log(row.name, row.delivered, `${row.engagement}%`);
}import os
import requests
response = requests.get(
"https://api.bubbl.tech/platform/v1/reports/campaigns",
headers={"Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}"},
params={"status": "active", "start": "2026-09-01", "end": "2026-09-30"},
timeout=30,
)
response.raise_for_status()
for row in response.json()["data"]:
print(row["name"], row["delivered"], f"{row['engagement']}%"){
"data": [
{
"id": "01926f3a-7d21-7a90-b3c4-1e2f3a4b5c6d",
"name": "Autumn launch",
"status": "active",
"type": "geo",
"targets": 12,
"delivered": 1204,
"engagement": 11.8
}
],
"meta": { "start": "2026-09-01", "end": "2026-09-30", "timezone": "Europe/London" }
}| Report | Each row | Statuses | Sorted by |
|---|---|---|---|
| Campaigns | id, name, status, type (geo or push), targets (locations it targets), delivered, engagement | active, scheduled, paused, ended | delivered |
| Locations | id, name, status, groups (group names), entries, exits, engagement, dwell_minutes | active (an active campaign targets it), idle | entries |
| Notifications | id, name, status, type (message or survey), sent, delivered, reach | active, scheduled, paused, ended, unused (on no campaign) | delivered |
| Surveys | id, campaign_id, campaign_name, survey_id, survey_name, trigger_type, questions, responses, status | active, scheduled, paused, ended | responses |
Rows are sorted highest first. Every campaign, location, notification or survey that matches the filters has a row, including those with nothing in the range, so the lists aren't paginated and there's no next_cursor.
- Engagement and reach are the same figure as in the dashboard: different devices reached in the range ÷ all registered devices × 100, to one decimal place. For a location, it's the devices that entered it.
dwell_minutesis how long a device typically stays: the time from each entry to that device's next exit at the location, averaged, to one decimal place. It'snullwhen no entry in the range was followed by an exit.senton a notification is deliveries plus push sends that failed, sosentminusdeliveredis how many failed.- A notification's status comes from the campaigns it's on:
activeif any of them is active, thenscheduled,pausedandendedin that order. - A survey row is one survey on one campaign. Its
ididentifies that pairing;survey_idis the notification's id, the one the notifications endpoints use.responsescounts the devices that answered. - The ids are the same ones the rest of the API uses, so you can pass a campaign's
idfrom this report as thecampaignfilter, or look it up withGET /campaigns/{id}.
When a request is refused#
The reports check every parameter and refuse one they can't use, rather than quietly reporting on something different. A bad parameter gets 422 with the code validation_failed, and fields says which parameter is wrong:
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"fields": {
"status": ["This report's statuses are: active, idle."]
}
}
}| Problem | Field |
|---|---|
A date that isn't YYYY-MM-DD, such as 01/09/2026 | start or end |
end before start | end |
More than 366 days between start and end | start |
| An id that isn't a UUID | campaign, location or location_group |
| A value that isn't in the lists above | activation, campaign_status, content_type or status |
A status this report doesn't have, such as idle on campaigns | status |
A token without reports:read gets 403 with the code missing_ability. See Errors for every code.
Tips#
- Pulling figures every day? Ask for yesterday on its own (
startandendboth yesterday) and append the rows to your table. Days are complete once they're over in your workspace's time zone. - Comparing campaigns? Use the same
startandendfor each and comparedeliveredandengagement. - Each request counts towards your rate limits, like any other read.