ChangelogEdit pageSTAFFOpen dashboard

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.

EndpointReturns
GET /reports/summaryTotals for the range, plus the same counts for each day
GET /reports/campaignsOne row per campaign
GET /reports/locationsOne row per location
GET /reports/notificationsOne row per notification
GET /reports/surveysOne 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
curl "https://api.bubbl.tech/platform/v1/reports/summary?start=2026-09-01&end=2026-09-30" \
  -H "Authorization: Bearer $BUBBL_API_TOKEN"
JAVASCRIPT
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);
PYTHON
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"])
RESPONSE
{
  "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" }
}
FieldWhat it counts
deliveredNotifications delivered to devices in the range.
devices_reachedDifferent devices that had at least one delivery.
geofence_entries, geofence_exitsTimes a device entered or left one of your locations.
campaigns, active_campaignsCampaigns that match your filters, and how many of them are active now.
registered_devicesAll 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#

ParameterFormatDefault
startYYYY-MM-DD30 days before end, so the range is 30 days
endYYYY-MM-DDToday
  • 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, and end includes the whole of its day.
  • end must be on or after start, and at most 366 days after it.
  • Every response has the range it covers, after defaults, under meta.start and meta.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.

ParameterValueNarrows to
campaignA campaign idThat campaign's deliveries, and geofence activity at its locations
locationA location idThat location, and the campaigns that target it
location_groupA location group idThe group's locations, and the campaigns that target the group
activationon_enter, on_exit or fixed_timeNotifications sent on entering a location, on leaving one, or at a scheduled time. on_enter counts only entries and on_exit only exits.
campaign_statusactive, scheduled, paused or endedCampaigns with that status now
content_typemessage or surveyNotifications 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
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"
JAVASCRIPT
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}%`);
}
PYTHON
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']}%")
RESPONSE
{
  "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" }
}
ReportEach rowStatusesSorted by
Campaignsid, name, status, type (geo or push), targets (locations it targets), delivered, engagementactive, scheduled, paused, endeddelivered
Locationsid, name, status, groups (group names), entries, exits, engagement, dwell_minutesactive (an active campaign targets it), idleentries
Notificationsid, name, status, type (message or survey), sent, delivered, reachactive, scheduled, paused, ended, unused (on no campaign)delivered
Surveysid, campaign_id, campaign_name, survey_id, survey_name, trigger_type, questions, responses, statusactive, scheduled, paused, endedresponses

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_minutes is 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's null when no entry in the range was followed by an exit.
  • sent on a notification is deliveries plus push sends that failed, so sent minus delivered is how many failed.
  • A notification's status comes from the campaigns it's on: active if any of them is active, then scheduled, paused and ended in that order.
  • A survey row is one survey on one campaign. Its id identifies that pairing; survey_id is the notification's id, the one the notifications endpoints use. responses counts the devices that answered.
  • The ids are the same ones the rest of the API uses, so you can pass a campaign's id from this report as the campaign filter, or look it up with GET /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:

RESPONSE
{
  "error": {
    "code": "validation_failed",
    "message": "The given data was invalid.",
    "fields": {
      "status": ["This report's statuses are: active, idle."]
    }
  }
}
ProblemField
A date that isn't YYYY-MM-DD, such as 01/09/2026start or end
end before startend
More than 366 days between start and endstart
An id that isn't a UUIDcampaign, location or location_group
A value that isn't in the lists aboveactivation, campaign_status, content_type or status
A status this report doesn't have, such as idle on campaignsstatus

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 (start and end both 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 start and end for each and compare delivered and engagement.
  • Each request counts towards your rate limits, like any other read.