ChangelogEdit pageSTAFFOpen dashboard

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:

ParameterWhat it does
limitHow many items per page, from 1 to 100. The default is 20; a larger number gets 100.
cursorWhere the next page starts. Leave it out for the first page, then pass the next_cursor from the page before.
updated_sinceOnly 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:

RESPONSE
{
  "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 422 with the code invalid_cursor.
  • Send the same limit and updated_since with 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#

CURL
# 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"
JAVASCRIPT
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');
PYTHON
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.

  1. 1
    Note the time before you start
    Before 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.
  2. 2
    Fetch every page since the last sync
    Call the list with updated_since set to the time you noted last time, and follow next_cursor to the last page. On your very first sync, leave updated_since out to fetch everything.
  3. 3
    Save each item by its id
    Insert 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.
  4. 4
    Store the new time
    When 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
curl "https://api.bubbl.tech/platform/v1/campaigns?limit=100&updated_since=2026-09-28T09:00:00Z" \
  -H "Authorization: Bearer $BUBBL_API_TOKEN"
JAVASCRIPT
// 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;
PYTHON
# 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_at
Deleted itemsLists don't include deleted items, so updated_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.