Concepts
Pagination and sync
Lists answer a page at a time with a cursor; updatedSince fetches only what changed since your last run.
Pages and cursors
Every list answers the same way: the items, the cursor for the next page, and whether there is one.
{
"data": [ { "sessionId": "b3ac0004-0000-4000-8000-000000000000", "title": "Registration and coffee" } ],
"nextCursor": "YzE6MDYzOTI3NjEwODQ4MjgyMDAwMHxiM2Fj...",
"hasMore": true
}Pass nextCursor back as cursor, with the same filters, until it is null. limit sets the page size, 100 by default and 500 at most. A cursor is opaque: keep it as it came, and do not build one yourself. One the server did not make is refused with invalidCursor rather than silently starting again.
Lists are in a fixed order, the oldest change first, with the id breaking ties, so a page never skips or repeats an item that did not change while you read. An item that changes while you page moves to the end and you see it again, which is what a sync wants.
The attendee, order and check-in lists also take the page and pageSize they took before cursors existed. They still work; prefer the cursor, which never counts.
Syncing: only what changed
Lists of things that change say when each item last changed, in updatedUtc, and take updatedSince. A sync keeps the newest updatedUtc it saw and asks from there next time:
- Read every page with
updatedSinceset to what you kept last time (nothing the first time). - Store or update each item by its id.
- Keep the newest
updatedUtcof the run for next time.
updatedSince is inclusive: an item changed in the same millisecond as your mark comes again rather than being missed. Updating an item you already have is harmless; missing one is not.
const base = "https://attendbeacon.com/api/v1/events/b3ac0000-0000-4000-8000-000000000001/attendees";
const headers = { Authorization: `Bearer ${process.env.ATTENDBEACON_KEY}` };
let since = await loadMark(); // null on the first run
let newest = since;
let cursor = null;
do {
const url = new URL(base);
url.searchParams.set("limit", "500");
if (since) url.searchParams.set("updatedSince", since);
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url, { headers }).then((r) => r.json());
for (const attendee of page.data) {
await upsert(attendee); // by attendee.attendeeId
if (!newest || attendee.updatedUtc > newest) newest = attendee.updatedUtc;
}
cursor = page.nextCursor;
} while (cursor);
await saveMark(newest);var since = await LoadMarkAsync(); // null on the first run
var newest = since;
string? cursor = null;
do
{
var query = $"events/b3ac0000-0000-4000-8000-000000000001/attendees?limit=500"
+ (since is null ? "" : $"&updatedSince={Uri.EscapeDataString(since)}")
+ (cursor is null ? "" : $"&cursor={cursor}");
var page = await client.GetFromJsonAsync<JsonElement>(query);
foreach (var attendee in page.GetProperty("data").EnumerateArray())
{
await UpsertAsync(attendee); // by attendeeId
var updated = attendee.GetProperty("updatedUtc").GetString()!;
if (newest is null || string.CompareOrdinal(updated, newest) > 0) newest = updated;
}
cursor = page.GetProperty("nextCursor").GetString();
}
while (cursor is not null);
await SaveMarkAsync(newest);base = "https://attendbeacon.com/api/v1/events/b3ac0000-0000-4000-8000-000000000001/attendees"
headers = {"Authorization": f"Bearer {os.environ['ATTENDBEACON_KEY']}"}
since = load_mark() # None on the first run
newest = since
cursor = None
while True:
params = {"limit": 500}
if since: params["updatedSince"] = since
if cursor: params["cursor"] = cursor
page = requests.get(base, params=params, headers=headers).json()
for attendee in page["data"]:
upsert(attendee) # by attendee["attendeeId"]
if newest is None or attendee["updatedUtc"] > newest:
newest = attendee["updatedUtc"]
cursor = page["nextCursor"]
if cursor is None:
break
save_mark(newest)$base = "https://attendbeacon.com/api/v1/events/b3ac0000-0000-4000-8000-000000000001/attendees"
$since = Get-Content mark.txt -ErrorAction SilentlyContinue # nothing on the first run
$newest = $since
$cursor = $null
do {
$query = "?limit=500"
if ($since) { $query += "&updatedSince=" + [uri]::EscapeDataString($since) }
if ($cursor) { $query += "&cursor=$cursor" }
$page = Invoke-RestMethod -Uri ($base + $query) -Headers $headers
foreach ($attendee in $page.data) {
Save-Attendee $attendee # by attendeeId
if (-not $newest -or $attendee.updatedUtc -gt $newest) { $newest = $attendee.updatedUtc }
}
$cursor = $page.nextCursor
} while ($cursor)
Set-Content mark.txt $newestTimes are ISO 8601 in UTC with three decimals and a Z, so they also compare correctly as text.
What has no change time
Rooms, tracks, speakers, sponsors and sponsor levels are small lists without a change time of their own: read them whole, with the cursor if you like. Their single resources and the event itself answer with an ETag, so asking again with If-None-Match answers 304 Not Modified, with no body, when nothing changed.
Check-ins never change once made: page through them by cursor, or by after, the last checkInId you have, which only grows.
Deletions and erasure
A cancelled ticket stays in the list with "status": "cancelled", so a sync sees the cancellation. When somebody deletes their data from the event app, their attendee stays with "erased": true and nothing personal; the attendee.erased webhook tells you at once, so you can delete your own copy. See Privacy and erasure.
Webhooks or polling
Polling with updatedSince every few minutes is simple and catches everything. Webhooks tell you within seconds. Many integrations do both: webhooks for speed, a nightly sync to be sure.