Sync data incrementally
Poll what changed since your last run instead of refetching a window and diffing, and avoid reacting to your own writes.
If you are building anything that runs on a schedule, a warehouse sync, an alert, a Slack digest, GET /v1/changes is the endpoint to build it on. It answers “what changed since my last run” directly, so you do not refetch a rolling window and diff it client-side.
The difference is not just tidiness. A window-and-diff job re-reads the same rows every run, gets slower as the workspace grows, and silently misses anything that changed outside the window it happened to pick.
The loop
Store the cursor from each run. Pass it back on the next one.
const state = await loadState(); // { cursor?: string }
const url = new URL("https://app.sourceloop.ai/api/v1/changes");
url.searchParams.set("website", "acme.com");
url.searchParams.set("limit", "500");
if (state.cursor) url.searchParams.set("cursor", state.cursor);
else url.searchParams.set("since", "2026-08-01T00:00:00Z"); // first run only
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SOURCELOOP_API_KEY}` },
});
const page = await res.json();
for (const change of page.changes) {
await apply(change);
}
if (page.next_cursor) await saveState({ cursor: page.next_cursor });
On the very first run there is no cursor, so pass since instead. With neither, you get the last 24 hours.
Keep paging while meta.has_more is true, and save the cursor only after the page has been applied successfully. Saving it first means a crash mid-page loses those changes permanently.
Changes are ordered newest first and retained for 90 days. A job that has been down for longer than that should do a full read rather than trying to resume.
Filter to what you actually handle
?types=conversion.created,deal.stage_changed
Available types include conversion.created, conversion.value_changed, stage.changed and deal.stage_changed. Filtering server-side is cheaper than fetching everything and discarding most of it, and it keeps your cursor moving at a sensible rate.
Do not react to your own writes
Every change carries a source. When your own job writes back through the API, the resulting change appears in the feed like any other, and a job that reacts to it will trigger itself forever.
for (const change of page.changes) {
if (change.source?.startsWith("api:")) continue; // our own write, skip it
await apply(change);
}
Writes made with an API key are stamped api:<keyId>, so you can skip your own specifically rather than skipping every programmatic change. This one line is the difference between a sync that settles and one that loops.
Scheduling
Poll on a schedule that matches how fresh the data needs to be. Every five minutes is plenty for a Slack alert; hourly is plenty for a warehouse. There is no webhook to wait on, which means no endpoint of yours to keep publicly reachable, and no signature to verify.
The endpoint needs conversions:read. Rate limits are per workspace, so if several jobs poll in parallel, stagger them rather than having them all fire on the hour. See Errors and rate limits ».
When to use something else
GET /v1/changes tells you what moved. It is not the right way to build a report: for aggregate numbers, ask /v1/metrics for the window you want, in one call, rather than reconstructing totals from a change feed.
A good rule: changes drive reactions, metrics drive reports.