Skip to content
Sursa Developers

Incremental sync with updated_since

To keep a copy of Sursa data in your own system, you do not need to read everything every time. The lists of objects that have updated_at accept updated_since and return only the objects changed at or after that moment.

curl -G https://api.sursa.com/v1/cards \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "updated_since=2026-10-23T00:00:00Z" \
  --data-urlencode "limit=200"

With updated_since, the list is sorted by updated_at ascending, then by id, and the cursor continues in that order. So you read the changes in the order they happened, and a page boundary never splits them in a way that loses one.

updated_since is accepted by these lists:

ListWhat counts as a change
GET /v1/cards, GET /v1/cards/countupdated_at of the card.
GET /v1/groupsupdated_at of the group: its title or settings.
GET /v1/webhooksupdated_at of the endpoint: its settings and secret, not its deliveries.
GET /v1/jobsupdated_at of the job: every change of its status.
GET /v1/draftsupdated_at of the draft.
GET /v1/cards/{card_id}/threadsThe latest creation or edit of the root comment or of any reply. The thread comes whole.
GET /v1/folders, GET /v1/collections/{name}/foldersupdated_at of the folder. The matching folders come as a flat list, see below.

Collections, users and announcements have no updated_at, so their lists do not filter by it: they are short, so read them whole on every run. The time must have a time zone offset, such as Z or +02:00; any other value is refused, as with GET /v1/cards.

With updated_since, a folder tree is no longer a tree: folders of each collection holds the changed folders as a flat list, with empty children, and parent_id tells where each one sits. GET /v1/folders then leaves out the collections without changed folders.

A sync loop

  1. On the first run, read the whole list without updated_since, and remember the moment the run started.
  2. On every next run, pass that remembered moment as updated_since, minus a small margin such as one minute, and remember the new start time.
  3. Write each object into your store by its id, replacing the previous copy. Objects that appear twice because of the margin are simply written again.
  4. Request every next page with the cursor and the same updated_since. A cursor works only with the filters of the first page: without them the request returns 400 with the code api.cursor_invalid.

Using the start time of the run, not the time it finished, makes sure that a change made while the run was going is picked up by the next one.

GET /v1/cards returns a JSON array, so its next page comes in the X-Next-Cursor header (see Lists that return an array):

const BASE = "https://api.sursa.com";
const headers = { Authorization: `Bearer ${process.env.SURSA_API_KEY}` };

async function syncCards(since, saveCard) {
  const startedAt = new Date();
  const params = new URLSearchParams({ limit: "200" });
  if (since) params.set("updated_since", new Date(since.getTime() - 60_000).toISOString());
  let query = params.toString();
  while (query) {
    const response = await fetch(`${BASE}/v1/cards?${query}`, { headers });
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
    for (const card of await response.json()) await saveCard(card); // write into your store by card.id
    const cursor = response.headers.get("X-Next-Cursor");
    if (cursor) params.set("cursor", cursor); // keep updated_since: the cursor needs the same filters
    query = cursor ? params.toString() : "";
  }
  return startedAt; // pass it as `since` next time
}

Deletions

A list only returns objects that exist, so it cannot tell you that something was deleted. Take deletions from the event feed:

curl -G https://api.sursa.com/v1/webhook-events \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "type=card.deleted" \
  --data-urlencode "created_since=2026-10-23T00:00:00Z"

Reading the feed needs a key with the admin role and the webhooks:manage scope. Events are kept for 30 days. If your sync has not run for longer than that, read the full list again and remove from your store every object that is no longer in it.

Export to a data warehouse puts lists, updated_since and the event feed together into one job.