Pagination and syncing
Lists come back a page at a time, newest first, with a cursor for the next page. Add updated_since to fetch only what's changed since your last sync.
Paginated lists#
GET /campaigns and GET /locations return a page at a time. Both take the same query parameters:
| Parameter | What it does |
|---|---|
limit | How many items per page, from 1 to 100. The default is 20; a larger number gets 100. |
cursor | Where the next page starts. Leave it out for the first page, then pass the next_cursor from the page before. |
updated_since | Only items created or changed at or after this time. See Syncing changes. |
Items are sorted newest first, by when they were created. Each response has the page in data and the next page's cursor in meta.next_cursor:
{
"data": [
{
"id": "01926f3a-7c1e-7b2a-9d4e-3f5a6b7c8d90",
"name": "Autumn launch",
"type": "geo",
"status": "scheduled",
"starts_at": "2026-10-01T09:00:00+00:00",
"ends_at": null,
"timezone": "Europe/London",
"paused": false,
"frequency_cap_count": null,
"frequency_cap_period": null,
"targeting": {
"location_ids": ["01926f2b-1a2b-7c3d-8e4f-5a6b7c8d9e0f"],
"location_group_ids": [],
"segmentation_ids": [],
"segment_match": "any"
},
"created_at": "2026-09-28T09:15:02+00:00",
"updated_at": "2026-09-28T09:15:02+00:00"
}
],
"meta": {
"next_cursor": "WyIyMDI2LTA5LTI4IDA5OjE1OjAyLjQ4MTIyMCIsIjAxOTI2ZjNhLTdjMWUtN2IyYS05ZDRlLTNmNWE2YjdjOGQ5MCJd"
}
}When next_cursor is null, you have the last page.
- Treat the cursor as opaque: pass it back exactly as you got it. A cursor Bubbl didn't give you gets
422with the codeinvalid_cursor. - Send the same
limitandupdated_sincewith every page of one listing. - Cursors mark a position, not a snapshot, so items created while you page through don't push others onto the next page: you never see an item twice or miss one because of them. New items appear at the start of the list, so you pick them up next time.
Fetch every page#
# First page
curl "https://api.bubbl.tech/platform/v1/locations?limit=100" \
-H "Authorization: Bearer $BUBBL_API_TOKEN"
# Next page: pass meta.next_cursor from the page before
curl "https://api.bubbl.tech/platform/v1/locations?limit=100&cursor=WyIyMDI2LTA5LTI4IDA5OjE1OjAyLjQ4MTIyMCIsIjAxOTI2ZjNhLTdjMWUtN2IyYS05ZDRlLTNmNWE2YjdjOGQ5MCJd" \
-H "Authorization: Bearer $BUBBL_API_TOKEN"async function listAll(path, params = {}) {
const items = [];
let cursor = null;
do {
const query = new URLSearchParams({ limit: '100', ...params });
if (cursor) query.set('cursor', cursor);
const response = await fetch(`https://api.bubbl.tech/platform/v1${path}?${query}`, {
headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` },
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const page = await response.json();
items.push(...page.data);
cursor = page.meta.next_cursor;
} while (cursor);
return items;
}
const locations = await listAll('/locations');import os
import requests
def list_all(path, **params):
items, cursor = [], None
while True:
query = {"limit": 100, **params}
if cursor:
query["cursor"] = cursor
response = requests.get(
f"https://api.bubbl.tech/platform/v1{path}",
headers={"Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}"},
params=query,
timeout=30,
)
response.raise_for_status()
page = response.json()
items.extend(page["data"])
cursor = page["meta"]["next_cursor"]
if not cursor:
return items
locations = list_all("/locations")Syncing changes#
To keep your own copy of your campaigns or locations up to date, don't fetch everything each time. Pass updated_since and you only get the items created or changed at or after that time.
- 1Note the time before you startBefore the first request of a sync, note the current time in UTC. That's where the next sync will start from, so nothing that changes while this sync runs is missed.
- 2Fetch every page since the last syncCall the list with
updated_sinceset to the time you noted last time, and follownext_cursorto the last page. On your very first sync, leaveupdated_sinceout to fetch everything. - 3Save each item by its idInsert or update each item in your copy by its
id. An item can come back in two syncs in a row if it changed at the moment you noted the time, so saving by id keeps your copy right. - 4Store the new timeWhen the last page is saved, store the time you noted in the first step for the next sync.
Write updated_since as an ISO 8601 time, and use Z for UTC, as in 2026-09-28T09:00:00Z. A time without a zone is read as UTC. A + in a query string is read as a space, so if you write an offset such as +01:00, URL-encode it as %2B01:00. A time Bubbl can't read gets 422 with the code invalid_parameter.
curl "https://api.bubbl.tech/platform/v1/campaigns?limit=100&updated_since=2026-09-28T09:00:00Z" \
-H "Authorization: Bearer $BUBBL_API_TOKEN"// Uses listAll() from above. lastSync is the time you stored after the previous sync.
const startedAt = new Date().toISOString();
const changed = await listAll('/campaigns', { updated_since: lastSync });
for (const campaign of changed) await saveCampaign(campaign); // insert or update by campaign.id
lastSync = startedAt;# Uses list_all() from above. last_sync is the time you stored after the previous sync.
from datetime import datetime, timezone
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
for campaign in list_all("/campaigns", updated_since=last_sync):
save_campaign(campaign) # insert or update by campaign["id"]
last_sync = started_atupdated_since doesn't tell you what's been deleted. To catch deletions, fetch the full list now and then and remove anything from your copy that's no longer in it.A campaign's notifications#
GET /campaigns/{id}/notifications isn't paginated. It returns all of the campaign's notifications in one response, oldest first, with no meta, cursor or updated_since. A campaign only has a handful.