Pagination and sync
List endpoints return one page at a time. This page explains the page envelope, the filters that make polling and incremental sync cheap, and the rules that keep a sync loop correct.
The list envelope
Section titled “The list envelope”A collection returns {"data": [...], "next_cursor": "...", "has_more": true}. On the last page next_cursor is null and has_more is false. An empty result is an empty array, never an error. Read the next page by passing next_cursor as the cursor query parameter. The list of products is the one exception: it keeps its older shape with next_cursor and an optional total, and it has no has_more.
Page size, sort and cursor rules
Section titled “Page size, sort and cursor rules”limit accepts 1 to 100 and defaults to 50; anything else returns 400 INVALID_PARAMETER. sort takes one of the fields listed for the endpoint, with a leading - for descending order, and defaults to -created_at. The cursor is opaque and is bound to the sort and filters of the request that produced it. Using it with different filters or a different sort returns 400 INVALID_CURSOR, so keep all parameters identical while paging and change only cursor.
Poll for new items with created_after
Section titled “Poll for new items with created_after”created_after returns items created at or after the given instant, in ISO 8601 (for example 2026-10-11T08:00:00Z; a plain date or a date-time without zone is read as UTC). Because the boundary is inclusive, the newest item you already have comes back once more, so always deduplicate by id. Sort ascending with sort=created_at so that a new item can never shift the pages you have not read yet.
Sync changes with updated_since
Section titled “Sync changes with updated_since”updated_since returns items changed at or after the instant, which is what an incremental sync needs. Sort by updated_at ascending, remember the largest updated_at you processed, and use it as the next updated_since. A resource without an updated_at rejects the parameter with 400 UNSUPPORTED_FILTER. The filter lists items that still exist, so detect removals with webhooks such as lead.archived, or reconcile with a full read from time to time.
Deduplicate by id
Section titled “Deduplicate by id”Every resource has a stable id (UUID; products use uuid). Treat the read as an upsert keyed by id: the same item can legitimately appear twice, once on the boundary of two syncs and once after an edit. Webhook deliveries are at least once as well, and their envelope carries a stable event id for the same purpose (see Webhooks).
Incremental sync in Node.js
Section titled “Incremental sync in Node.js”This reads every lead changed since a checkpoint and returns the new checkpoint. It needs Node.js 18 or newer and the scope leads:read.
const BASE = "https://api.dakoty.com/api/v1/public";const headers = { Authorization: `Bearer ${process.env.DAKOTY_API_KEY}` };
async function* listAll(path, params = {}) { let cursor = null; do { const qs = new URLSearchParams({ limit: "100", ...params }); if (cursor) qs.set("cursor", cursor); const res = await fetch(`${BASE}${path}?${qs}`, { headers }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const page = await res.json(); yield* page.data; cursor = page.has_more ? page.next_cursor : null; } while (cursor);}
export async function syncLeads(checkpoint, store) { let latest = checkpoint; for await (const lead of listAll("/leads", { updated_since: checkpoint, sort: "updated_at" })) { store.set(lead.id, lead); if (lead.updated_at && lead.updated_at > latest) latest = lead.updated_at; } return latest;}Incremental sync in Python
Section titled “Incremental sync in Python”The same loop with requests. Keep params unchanged between pages so the cursor stays valid.
import osimport requests
BASE = "https://api.dakoty.com/api/v1/public"HEADERS = {"Authorization": f"Bearer {os.environ['DAKOTY_API_KEY']}"}
def list_all(path, **params): cursor = None while True: query = {"limit": 100, **params} if cursor: query["cursor"] = cursor r = requests.get(BASE + path, headers=HEADERS, params=query, timeout=30) r.raise_for_status() page = r.json() yield from page["data"] if not page["has_more"]: return cursor = page["next_cursor"]
def sync_leads(checkpoint, store): latest = checkpoint for lead in list_all("/leads", updated_since=checkpoint, sort="updated_at"): store[lead["id"]] = lead latest = max(latest, lead["updated_at"] or latest) return latestExact lookups with the find endpoints
Section titled “Exact lookups with the find endpoints”To check whether a record already exists before creating it, use the /find endpoints for companies, contacts, leads and invoices. They do an exact match, need at least one query parameter (for example registration_id, email or phone for companies) and return an empty list when nothing matches. Creating a lead can also deduplicate on its own with dedupe=company; see the API reference.