Skip to content
Beta

Guides Keep a copy in sync

Keep a copy in sync

Copy a team with the changes feed and keep the copy current. Apply pages safely, recover from errors, and start from a reference client that Totem tests.

The changes feed, GET /v1/sync/changes, is how you keep your own copy of a team. Your first request copies every record. After that, each request returns only what changed since the last one, so a sync every few minutes is cheap and never misses a change.

The sync loop for one teamStart without a cursor, read a page of the changes feed, apply it, save its cursor, and ask again while has_more is true; once caught up, wait 1 to 5 minutes. The steps are listed after the diagram.yesnoStart without a cursorGET /v1/sync/changeswith Totem-Team and your cursorApply the pageSkip versions you already hold.Keep a tombstone for each removal.Save next_cursoronly after the whole page is storedhas_more?Wait 1–5 minutesthen ask again with your cursor
What to do when a changes-feed request failsOne action per error answer. The same list follows the diagram.410 sync_cursor_expired409 resync_requiredFull resync: forget the versions,then mark and sweep.409 capture_not_readyTotem is still preparing the team.Retry in a few minutes.403Keep the data and the cursor.Try again later.404 team_not_accessibleConfirm with GET /v1/teams,then drop the team.429Wait for Retry-After, then retrythe same cursor.5xxRetry with exponential backoff.Never save a cursor after an error.
  1. Start a team without a cursor: your first request to GET /v1/sync/changes begins its initial sync.
  2. Call GET /v1/sync/changes with the team in Totem-Team and your saved cursor.
  3. Apply the page: skip any item whose version you already hold, store each record, and delete each removed record while keeping its version as a tombstone.
  4. Save next_cursor, only after the whole page is stored.
  5. If has_more is true, ask again at once. If it is false, you are up to date: wait 1 to 5 minutes, then ask again with the saved cursor.
  6. If the request fails:
    • 410 sync_cursor_expired or 409 resync_required: Full resync: forget the versions, then mark and sweep.
    • 409 capture_not_ready: Totem is still preparing the team. Retry in a few minutes.
    • 403: Keep the data and the cursor. Try again later.
    • 404 team_not_accessible: Confirm with GET /v1/teams, then drop the team.
    • 429: Wait for Retry-After, then retry the same cursor.
    • 5xx: Retry with exponential backoff.
    Never save a cursor after an error.

How the feed works

There is one feed per team. A key that reads several teams names the team in the Totem-Team header and keeps one cursor per team.

Each item of the feed is the current state of one record, of one kind:

  • lead, lead_attribution and lead_response: a lead and its two facets;
  • activity: a change of a lead's stage, status or assigned members;
  • call, conversation and booking;
  • board, with its stages inside it.

A lead's attribution and response travel as kinds of their own, with the lead's id and a version of their own. That is why a new message changes the lead's response without changing the lead. Members and bookable services are not in the feed: you read them separately.

Items come in the order Totem recorded the changes, oldest first. An item is never a diff: it carries the whole record as it is now.

Records and removals

Most items carry the record under object. It is the same object the list endpoints return, shortened here:

JSON
{
  "kind": "call",
  "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk",
  "version": 2,
  "object": {
    "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk",
    "object": "call",
    "livemode": false,
    "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR",
    "direction": "inbound",
    "outcome": "answered_ai",
    "version": 2
  }
}

A removal carries removed instead of object. It tells you to delete the record:

JSON
{
  "kind": "call",
  "id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V",
  "version": 5,
  "removed": { "reason": "deleted" }
}

reason is erased when the record was erased: for privacy, for example at the person's request, or because someone permanently deleted a lead, a call or a conversation in Totem, which always erases it. It is deleted when the record went away in any other way, for example when it no longer belongs to the team's API data, or when Totem now names it with a new id. Handle both the same way, as Apply a page explains. A record that comes back is a new record with a new id (Conventions), so match people across ids by phone or email with search, never by id.

Plan your storage

A copy built this way can apply every page safely, whatever order the records arrive in:

  • One row per record, keyed by team, kind and id, with its version. A lead and its two facets share an id, so the kind is part of the key.
  • One tombstone per removal: the kind, id and version of a record you deleted, without its data.
  • One cursor per team, saved together with the page it follows when your database allows it.
  • Boards, members and bookable services in lists of their own, replaced as a whole each time you read them.
  • No foreign keys between records while you apply pages. A record can name another that has not reached you yet, such as a lead naming a stage of a board, or an activity naming its lead.

Then run one sync per key every 1 to 5 minutes, and watch for a team that keeps being skipped or postponed, or whose cursor has not been saved for a day.

Run the initial sync

Your first request for a team has no cursor. It starts the initial sync: the feed pages through every record of the team, oldest change first.

cURL
curl "https://api.totemstudio.ai/v1/sync/changes?limit=100" \
  -H "Authorization: Bearer $TOTEM_API_KEY" \
  -H "Totem-Team: $TOTEM_TEAM_ID"

Apply the page, store its next_cursor, and ask again with cursor set to it. Repeat while has_more is true. When has_more is false, your copy is complete. Ask for the largest page, 100 items, so the initial sync takes as few requests as possible.

Two things are normal during an initial sync. A record that changes while it runs comes again later, with a higher version. And after a retry, you can receive the same page twice. The version check in the next section makes both harmless.

Apply a page

For each item of the page, in order:

  1. Skip what you already have. If you hold this kind and id with the same or a higher version, skip the item. Compare with the versions of your tombstones too.
  2. Store a record. If the item has object, insert your copy or replace it. For a lead, store its contact_permissions before anything else can act on it.
  3. Apply a removal. If the item has removed, delete your copy, but keep its kind, id and version as a tombstone. When the removed record is a lead, also delete its attribution, its response and its activities, and ignore any of them that arrives later.

Save the cursor

Store next_cursor only after the whole page is saved. If your process stops in between, the next run reads the same page again, and step 1 skips what you already applied. When your database allows it, save the page and its cursor in one transaction.

A cursor is opaque: store it exactly as you received it, and never build or edit one. It belongs to one team. Sent with another team, it gets 400 invalid_request.

A cursor expires 30 days after the point in the feed it reached. While an initial sync is still running, that age counts from when the initial sync started. A team you poll regularly never reaches it.

Poll for changes

Once has_more is false, ask again with your stored cursor every 1 to 5 minutes. When nothing has changed, you get an empty page with a new next_cursor. Store it too: an empty page moves the cursor forward, so the cursor of a quiet team never grows old.

The feed holds back the last 10 seconds of changes, so that a change still being written is never skipped. A change shows up in the feed shortly after it happens in Totem.

Run at most one sync process per key and team. Two processes applying pages of the same team can save their cursors out of order.

Removals, merges and transfers

Removals

Apply a removal as soon as you receive it: delete the record from your copy and from every system you copied it to. Removals stay in the feed for 45 days, longer than a cursor lives, so a sync that keeps reading the feed successfully never misses one. While the API refuses a team, it delivers nothing for that team, removals included; Recover from errors says what to do then.

When a lead is removed, its attribution, response and activities are removed with it, and the feed delivers their removals too. What happens to the lead's other records depends on the reason:

  • erased, which includes a lead deleted permanently in Totem: its calls and conversations are erased too, and so are the archived leads whose merged_into names it, with their own calls and conversations. Each record arrives as a removal of its own, with reason: "erased". Its bookings are not removed: they arrive again with lead set to null.
  • deleted: its calls, conversations and bookings are not removed. They arrive again with lead set to null.

Merges

When two leads of the same person are merged in Totem, the lead that is not kept is archived, not removed. Its archive_reason is merged, and its merged_into names the lead that was kept. Link your copy to that lead instead of creating the person twice. The lead that is kept also takes the stricter contact permissions of the two. merged_into is null when the lead it named no longer exists.

Transfers

When a lead moves to another team of the same organization, the original lead is archived with archive_reason set to family_transfer, and transferred_to says where it went:

  • transferred_to.team is always set. It can be a team your key does not read.
  • transferred_to.lead is the lead in that team, or null when Totem cannot name it.

If your key reads the destination team, the new lead arrives in that team's feed. If it does not, GET /v1/leads/{id} on the destination lead answers 404 resource_missing. Keep the archived lead as the record of where the person went, and do not guess.

Contact permissions first

Before any of your systems contacts a lead, store its contact_permissions and apply them:

  • do_not_contact: true: the person asked never to be contacted. Stop every channel.
  • email_opt_out: true: the person unsubscribed from marketing and follow-up email.
  • whatsapp_consent: false: the person refused WhatsApp. null means nobody recorded an answer, so never treat it as consent.

A lead in the feed is data, not permission to contact the person. The legal basis for contacting someone stays your responsibility.

The API is read-only, so Totem cannot learn about an opt-out recorded in your systems. When a person opts out there, make sure it is also recorded on the lead in Totem, so that Totem stops contacting them too.

Recover from errors

When a request for a page fails, do not save a cursor. The next request with the same cursor asks for the same page again.

AnswerWhat it meansWhat to do
410 sync_cursor_expiredThe cursor is more than 30 days old.Run a full resync.
409 resync_requiredTotem cannot continue from this cursor, for example after restoring a backup.Run a full resync.
409 capture_not_readyTotem is still preparing this team's data. It happens once, when a team is first added to a key.Keep everything and try again in a few minutes. Never resync for it. The key's other teams keep syncing.
403Access is refused for now: for example, the team is restricted (team_state_restricted) or the endpoint is switched off (capability_disabled).Keep the team's data and cursor, and skip the team in this run. The Errors page says, code by code, whether waiting helps. plan_not_entitled refuses the whole key, so the run stops at GET /v1/teams. While access is refused, the feed delivers nothing for the team, removals included: if the refusal lasts longer than your retention policy allows, delete the team's copy.
404 team_not_accessibleThe key may no longer read this team.Read GET /v1/teams. If the team is not listed, delete its data and its cursor. If it is, keep everything and try again on the next run.
400 invalid_requestThe cursor was changed, or it belongs to another team.Use the cursor you stored for this team.
429You reached the rate limit or the daily quota.Wait the seconds in Retry-After, then retry with the same cursor. A 429 from the firewall has no Retry-After: back off exponentially.
5xxSomething failed inside Totem.Retry with exponential backoff.

Every code is explained on the Errors page.

Full resync

A full resync rebuilds your copy of one team, by mark and sweep:

  1. Forget the versions and the cursor you stored for the team. Keep the records themselves: your copy stays usable while the resync runs.
  2. Run the initial sync again, without a cursor. Apply each page as usual, and mark every record the feed delivers.
  3. When has_more is false, delete every record of the team that you did not mark.

Store the marks durably. If the process stops halfway, the next run must continue the same resync, not sweep with half the marks. Forgetting the versions first matters too: after a restore, the feed can deliver lower versions than the ones you held.

When teams change

Read GET /v1/teams at the start of every run. It lists every team your key reads, including teams that are restricted for now.

  • A team you have not synced yet: run its initial sync.
  • A team you hold that is no longer listed: it has left the key. Delete its data and its cursor.
  • A listed team that answers 403: keep it, as above.

Refresh boards, members and services

Members and bookable services are not in the feed. Boards are, but a lead can name a new stage before the change to its board reaches you. So read all three:

  • when you start syncing a team;
  • when an item names a board, stage, member or service you do not know;
  • at least once a day.

A record can keep naming a member who left the team, or a board, stage or service that was deleted. If a refresh does not find an id, remember it and do not refresh for it again before the daily refresh.

GET /v1/boards pages like any list. GET /v1/members and GET /v1/booking_services return the whole list in one response and take no limit or cursor. Replace your copy of each list as a whole. A member who leaves the team, or a service that is deleted, drops out of its list while older records keep naming its id: if you show names for older records, keep the name from an earlier read. Disabled members and services stay listed, with nothing that marks them as disabled.

Pace

A key reads up to 2,000 items a minute, so the initial sync of a large team takes a while. Rate limits has the numbers, an estimate of how long an initial sync takes, and why the numbers are approximate during the Beta. When you get a 429, wait for Retry-After and continue with the same cursor: nothing is lost. The limits belong to the key, so a 429 pauses every team the key reads, not just the one you were syncing.

Tested reference client

The TypeScript below is a complete sync client that does everything this page describes. Totem runs it in its own tests against a simulated feed, with crashes, replayed pages, removals, resyncs and errors; the code shown here is that exact file. It has no dependencies and needs only fetch: compile it with your TypeScript build, or run it directly on Node.js 22.18 or later. Copy it to your server and implement SyncStore on your database.

Run it from a scheduled job every 1 to 5 minutes, one run per key at a time. The example is an ES module; under Node.js module resolution, import the file with the .js extension your build emits, or with .ts when Node.js runs the TypeScript directly:

TypeScript
import { syncKey } from './syncTeam.js'

const outcomes = await syncKey({ key: process.env.TOTEM_API_KEY!, store })

The key must never reach a browser. Every SyncStore method must write durably before its promise resolves, and writes for one team must not run in parallel.

Options and outcomes

syncKey reads GET /v1/teams, deletes the teams that left the key, and syncs every listed team in turn. It returns one outcome per team:

  • synced: the team is caught up and its cursor is saved.
  • partial: the team read maxPagesPerTeam pages and is not caught up yet. Its cursor is saved, and the next run continues from it.
  • dropped: the team left the key, and its copy and cursor are deleted.
  • skipped, with the code: the team cannot be read right now (403, capture_not_ready, or a team_not_accessible that GET /v1/teams does not confirm). Its copy and cursor are kept, and the next run tries again.
  • retry_later, with the code and retryAt (epoch milliseconds): the API asked for a longer wait than this run allows, or a 429 or 5xx was still failing after maxRetries retries. The copy and cursor are kept. Do not sync that team again before retryAt. A 429 belongs to the key, not to one team, so once one sends the key away, every team this run has not reached yet is reported retry_later with the same code and retryAt, without a request.

A team that is restricted, still being prepared or gone never stops the others; a 429 this run cannot wait out pauses the whole key until retryAt. syncKey throws when the key itself is refused, when GET /v1/teams cannot be read, or on an error that points to a bug rather than to the team: a 400, an unexpected 404, a feed that still asks for a full resync right after one, a list or feed that repeats a cursor or leaves one out, or a network failure. No cursor is lost then either; when the error carries retryAt, schedule the next run for that time.

Besides key and store, it takes:

  • maxWaitMs: the longest it sleeps in one go, in milliseconds. The default is 60,000. A longer Retry-After, such as the wait for the daily quota to reset at 00:00 UTC, ends the run with retry_later instead of keeping your process asleep.
  • maxRetries: how many times one request is retried after a 429 or a 5xx. The default is 3.
  • maxPagesPerTeam: the most feed pages one team reads in one run. There is no limit by default. With a limit, a long initial sync stops with partial, the key's other teams are synced, and the next run continues from the saved cursor.
  • fetch, baseUrl, sleep and now: replace them in your tests.
syncTeam.tsTypeScript
/**
 * Reference receiver for the Totem changes feed. No dependencies: it needs
 * only `fetch`. Compile it with your TypeScript build, or run it directly on
 * Node.js 22.18 or later.
 *
 * It keeps a copy of every team a key reads, safely across crashes:
 * - Run it on your server only. The key is a secret and must never reach a browser.
 * - Run one `syncKey` per key at a time, every 1 to 5 minutes.
 * - Persist the `SyncStore` in your own database. `MemorySyncStore`
 *   (memoryStore.ts) loses everything on restart and is for tests only.
 * - Each page is applied before its cursor is saved, so a crash in between
 *   replays the page, and the version check makes the replay harmless.
 * - One team's trouble never stops the others: a team that cannot be read
 *   right now keeps its copy and its cursor, and the next run tries again.
 * - It stops instead of looping if the API ever repeats a cursor or sends
 *   none, before saving it, so a fault on either side cannot spend the key's
 *   daily quota.
 */

export interface FeedItem {
  kind: string
  id: string
  version: number
  object?: Record<string, unknown>
  removed?: { reason: string }
}

export interface FeedPage {
  object: 'list'
  data: FeedItem[]
  has_more: boolean
  next_cursor: string
}

interface ListPage {
  data: unknown[]
  has_more: boolean
  next_cursor: string | null
}

/** Your persistence. Every method must be durable before its promise resolves. */
export interface SyncStore {
  teamIds(): Promise<string[]>
  ensureTeam(teamId: string): Promise<void>
  cursor(teamId: string): Promise<string | null>
  /** The version you hold for this record, removals included; 0 when you hold none. */
  version(teamId: string, kind: string, id: string): Promise<number>
  setContactPermissions(teamId: string, leadId: string, permissions: Record<string, unknown>): Promise<void>
  upsert(teamId: string, item: FeedItem): Promise<void>
  /**
   * Delete the record but keep its version (a tombstone). Removing a lead also
   * deletes its attribution, response and activities.
   */
  remove(teamId: string, item: FeedItem): Promise<void>
  saveCursor(teamId: string, cursor: string): Promise<void>
  /** Start a full resync: forget the team's versions and cursor, keep its records, clear the seen set. */
  startResync(teamId: string): Promise<void>
  /** Remember that the feed still delivers this record. Must survive a restart until the resync finishes. */
  markSeen(teamId: string, item: FeedItem): Promise<void>
  /** End a full resync: delete the team's records the feed did not deliver. A no-op when no resync is running. */
  finishResync(teamId: string): Promise<void>
  /** Delete everything you hold for the team, cursor included. */
  dropTeam(teamId: string): Promise<void>
  dictionariesAt(teamId: string): Promise<number | null>
  saveDictionaries(teamId: string, dictionaries: Record<string, unknown[]>, at: number): Promise<void>
  /**
   * True when the item names a board, stage, member or service that is neither
   * in your dictionaries nor remembered as unresolved (`rememberUnresolved`).
   */
  hasUnknownReferences(teamId: string, item: FeedItem): Promise<boolean>
  /**
   * Called for an item that still names unknown ids right after the
   * dictionaries were refreshed. A record can keep naming a member who left
   * the team, or a board, stage or service that was deleted: remember those
   * ids so they never cause another refresh. The daily refresh still picks
   * up any that come back.
   */
  rememberUnresolved(teamId: string, item: FeedItem): Promise<void>
}

export interface SyncOptions {
  /** A secret key (`tsk_live_…`). */
  key: string
  store: SyncStore
  fetch?: typeof globalThis.fetch
  baseUrl?: string
  sleep?: (ms: number) => Promise<void>
  now?: () => number
  /** How many times one request is retried after a `429` or a `5xx`. Default 3. */
  maxRetries?: number
  /**
   * The longest this process waits in one go, in milliseconds. Default 60 000.
   * A longer `Retry-After` (the daily quota resets at 00:00 UTC) ends that
   * team's run with `retryAt` instead of keeping the process asleep.
   */
  maxWaitMs?: number
  /**
   * The most feed pages one team reads in one run. No limit by default. A
   * team's initial sync can take hours: with a limit, it stops with `partial`
   * after that many pages, the key's other teams are synced, and the next run
   * continues from the saved cursor.
   */
  maxPagesPerTeam?: number
}

/** What happened to one team in one `syncKey` run. */
export type TeamOutcome =
  /** Caught up; the cursor is saved. */
  | { team: string, status: 'synced' }
  /** Read `maxPagesPerTeam` pages and not caught up yet; the cursor is saved and the next run continues. */
  | { team: string, status: 'partial' }
  /** The team left the key: its copy and cursor are deleted. */
  | { team: string, status: 'dropped' }
  /** Not readable right now (`403`, `capture_not_ready`, unconfirmed `team_not_accessible`); copy and cursor kept. */
  | { team: string, status: 'skipped', code: string }
  /**
   * Asked to wait longer than `maxWaitMs`, or still failing after `maxRetries`;
   * copy and cursor kept. Do not call before `retryAt` (epoch milliseconds).
   */
  | { team: string, status: 'retry_later', code: string, retryAt: number }

/** An error answer from the API. `retryAt` (epoch milliseconds) is set when asking again later can succeed. */
export class SyncHttpError extends Error {
  readonly status: number
  readonly code: string
  readonly retryAt: number | null

  constructor(status: number, code: string, retryAt: number | null = null) {
    super(`Totem API answered ${status} ${code}`)
    this.name = 'SyncHttpError'
    this.status = status
    this.code = code
    this.retryAt = retryAt
  }
}

const DEFAULT_BASE_URL = 'https://api.totemstudio.ai/v1'
const DAY_MS = 86_400_000

/**
 * The API always moves a cursor forward. Throws when a page says there is
 * more but repeats a cursor this loop already used, or sends none: following
 * it would read the same page forever.
 */
function nextCursor(seen: Set<string>, cursor: string | null): string {
  if (!cursor || seen.has(cursor)) throw new Error('Totem API sent a missing or repeated cursor; stopping so the loop cannot spend the key\'s quota')
  seen.add(cursor)
  return cursor
}

/** `Retry-After` is always whole seconds. Null when the header is missing or unreadable. */
function retryAfterMs(response: Response): number | null {
  const header = response.headers.get('Retry-After')
  if (header === null || header.trim() === '') return null
  const seconds = Number(header)
  return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null
}

/** One GET. A `429` or `5xx` is retried while the wait fits within `maxWaitMs`. */
async function get<T>(options: SyncOptions, path: string, team?: string): Promise<T> {
  const fetcher = options.fetch ?? globalThis.fetch
  const sleep = options.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
  const now = options.now ?? Date.now
  const maxRetries = options.maxRetries ?? 3
  const maxWaitMs = options.maxWaitMs ?? 60_000

  for (let attempt = 0; ; attempt++) {
    const response = await fetcher(`${options.baseUrl ?? DEFAULT_BASE_URL}${path}`, {
      headers: { Authorization: `Bearer ${options.key}`, ...(team ? { 'Totem-Team': team } : {}) },
    })
    if (response.ok) return await response.json() as T

    // Errors are application/problem+json. The firewall in front of the API may answer without a body.
    const problem = await response.json().catch(() => ({})) as { code?: string }
    const code = problem.code ?? `http_${response.status}`
    // Only a 429 or a 5xx is worth sending again within this run; the caller decides about the rest.
    if (response.status !== 429 && response.status < 500) throw new SyncHttpError(response.status, code)

    const waitMs = retryAfterMs(response) ?? 1000 * 2 ** attempt
    // Too long to wait here (the daily quota, say), or out of retries: report when to come back.
    if (waitMs > maxWaitMs || attempt >= maxRetries) throw new SyncHttpError(response.status, code, now() + waitMs)
    await sleep(waitMs)
  }
}

/** Every team `GET /v1/teams` lists. It lists restricted teams too, so a missing team has left the key. */
async function listedTeams(options: SyncOptions): Promise<string[]> {
  const page = await get<{ data: Array<{ id: string }> }>(options, '/teams')
  return page.data.map(team => team.id)
}

/**
 * Members and bookable services are not in the feed: read them in full when a
 * team starts, when an item names an id you do not know, and once a day.
 * Boards do travel in the feed; they are read here as well because a lead can
 * name a new stage before that board's change reaches you.
 */
async function refreshDictionaries(options: SyncOptions, teamId: string): Promise<void> {
  const boards: unknown[] = []
  const seen = new Set<string>()
  let cursor: string | null = null
  do {
    const query: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
    const page: ListPage = await get<ListPage>(options, `/boards?limit=100${query}`, teamId)
    boards.push(...page.data)
    cursor = page.has_more ? nextCursor(seen, page.next_cursor) : null
  } while (cursor)
  // These two arrive whole, in one response, and take no `limit` or `cursor`.
  const members = await get<ListPage>(options, '/members', teamId)
  const services = await get<ListPage>(options, '/booking_services', teamId)
  const now = options.now ?? Date.now
  const dictionaries = { boards, members: members.data, booking_services: services.data }
  await options.store.saveDictionaries(teamId, dictionaries, now())
}

/** Applies one page. Every item is checked against the version you hold, removals included. */
async function applyPage(options: SyncOptions, teamId: string, page: FeedPage): Promise<void> {
  const { store } = options
  let refreshed = false
  for (const item of page.data) {
    // Apply only what is newer than your copy. A removal keeps its version as a
    // tombstone, so an older copy replayed later cannot bring the record back.
    if (item.version > await store.version(teamId, item.kind, item.id)) {
      if (await store.hasUnknownReferences(teamId, item)) {
        // One refresh per page at most. What it does not resolve (a member who
        // left, a deleted stage) is remembered, so it never asks again.
        if (!refreshed) {
          await refreshDictionaries(options, teamId)
          refreshed = true
        }
        await store.rememberUnresolved(teamId, item)
      }
      if (item.removed) {
        await store.remove(teamId, item)
      }
      else {
        // Commit contact permissions before anything downstream can act on the lead.
        const permissions = item.kind === 'lead' ? item.object?.contact_permissions : undefined
        if (permissions && typeof permissions === 'object') {
          await store.setContactPermissions(teamId, item.id, permissions as Record<string, unknown>)
        }
        await store.upsert(teamId, item)
      }
    }
    // During a full resync, remember every record the feed still delivers.
    await store.markSeen(teamId, item)
  }
}

/**
 * Brings one team's copy up to date: reads the feed from the saved cursor
 * until it has caught up (`synced`), or until it has read `maxPagesPerTeam`
 * pages (`partial`). Throws `SyncHttpError` when the API refuses; the saved
 * cursor is untouched, so the next run resumes from the same place.
 */
export async function syncTeam(options: SyncOptions & { teamId: string }): Promise<'synced' | 'partial'> {
  const { store, teamId } = options
  const now = options.now ?? Date.now
  await store.ensureTeam(teamId)
  const dictionariesAt = await store.dictionariesAt(teamId)
  if (dictionariesAt === null || now() - dictionariesAt >= DAY_MS) await refreshDictionaries(options, teamId)

  let resyncing = false
  let pages = 0
  let seen = new Set<string>()
  for (;;) {
    const cursor = await store.cursor(teamId)
    const query = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
    let page: FeedPage
    try {
      page = await get<FeedPage>(options, `/sync/changes?limit=100${query}`, teamId)
    }
    catch (error) {
      // The cursor expired (410) or the feed cannot continue from it (409 resync_required):
      // run a full resync, once per run.
      const restart = error instanceof SyncHttpError && (error.status === 410 || error.code === 'resync_required')
      if (restart && !resyncing) {
        await store.startResync(teamId)
        resyncing = true
        seen = new Set()
        continue
      }
      throw error
    }
    await applyPage(options, teamId, page)
    // Check the cursor before saving it, so a missing or repeated one never
    // replaces the last good cursor. A last page only has to send one.
    if (cursor) seen.add(cursor)
    if (page.has_more || !page.next_cursor) nextCursor(seen, page.next_cursor)
    // Save the cursor only after the page is applied, and save it for an empty
    // page too: that keeps the cursor of a quiet team from expiring.
    await store.saveCursor(teamId, page.next_cursor)
    if (!page.has_more) {
      // After a full resync, delete what the feed no longer delivers.
      await store.finishResync(teamId)
      return 'synced'
    }
    pages++
    // Leave the rest for the next run, so the key's other teams are not kept waiting.
    if (options.maxPagesPerTeam !== undefined && pages >= options.maxPagesPerTeam) return 'partial'
  }
}

/**
 * What one team's error means for this run. Every outcome but `dropped` keeps
 * the team's copy and cursor. Any other error (a `401`, a `400`, an unexpected
 * `404`, a feed that still asks for a resync after one) concerns the whole key
 * or a bug, so it is rethrown and stops the run.
 */
async function outcomeOf(options: SyncOptions, team: string, error: SyncHttpError): Promise<TeamOutcome> {
  // A 429 or 5xx that asked for a longer wait than this run allows (or kept failing).
  if (error.retryAt !== null) return { team, status: 'retry_later', code: error.code, retryAt: error.retryAt }
  // Restricted, sector-restricted or switched off: keep everything and skip the team.
  if (error.status === 403) return { team, status: 'skipped', code: error.code }
  // Totem is still preparing this team's data (once per team, ever): a later run reads it.
  if (error.code === 'capture_not_ready') return { team, status: 'skipped', code: error.code }
  // The team may have left the key. Only `/teams` can confirm it; a team it still lists keeps its copy.
  if (error.code === 'team_not_accessible') {
    if ((await listedTeams(options)).includes(team)) return { team, status: 'skipped', code: error.code }
    await options.store.dropTeam(team)
    return { team, status: 'dropped' }
  }
  throw error
}

/**
 * Syncs every team the key reads and reports what happened to each. Re-reads
 * `/teams` on every run, so a new team starts its initial sync and a team that
 * left the key is deleted. Once a `429` (the daily quota, say) sends the key
 * away for longer than `maxWaitMs`, the teams not reached yet are reported
 * `retry_later` for the same time, without a request.
 *
 * Throws when the key itself is refused, when `/teams` cannot be read, when a
 * team's answer points to a bug rather than to the team (a `400`, say), or
 * when the network fails. No cursor is lost. When the error carries `retryAt`
 * (the daily quota is spent, for example), schedule the next run for then.
 */
export async function syncKey(options: SyncOptions): Promise<TeamOutcome[]> {
  const outcomes: TeamOutcome[] = []
  const listed = await listedTeams(options)
  for (const team of await options.store.teamIds()) {
    if (listed.includes(team)) continue
    await options.store.dropTeam(team)
    outcomes.push({ team, status: 'dropped' })
  }
  // Set when a `429` sends the key away for longer than this run waits.
  let keyWait: { code: string, retryAt: number } | null = null
  for (const team of listed) {
    if (keyWait) {
      outcomes.push({ team, status: 'retry_later', ...keyWait })
      continue
    }
    try {
      outcomes.push({ team, status: await syncTeam({ ...options, teamId: team }) })
    }
    catch (error) {
      if (!(error instanceof SyncHttpError)) throw error
      const outcome = await outcomeOf(options, team, error)
      outcomes.push(outcome)
      // A `429` concerns the key, never one team: the daily quota and the rate
      // limit are both the key's. The teams not reached yet would be refused
      // too, so they wait for the same moment without spending a request.
      if (error.status === 429 && outcome.status === 'retry_later') keyWait = { code: outcome.code, retryAt: outcome.retryAt }
    }
  }
  return outcomes
}

In-memory store for tests

MemorySyncStore implements every SyncStore method in memory. It shows what each method must do and makes your tests deterministic. It loses everything on restart, so replace it with your database before you go live.

memoryStore.tsTypeScript
/**
 * An in-memory `SyncStore`, for tests and for reading alongside syncTeam.ts.
 * It loses everything on restart: in production, implement every method on
 * your database, durably, and commit a page and its cursor together when you can.
 */
import type { FeedItem, SyncStore } from './syncTeam.js'

/** One key per record: a lead and its two facets share an id but not a kind. */
const keyOf = (item: Pick<FeedItem, 'kind' | 'id'>) => `${item.kind}/${item.id}`

/**
 * Every board, stage, service and member id an item names: a lead's `board`,
 * `stage` and `assignees`, a conversation's `assignee`, a booking's `service`,
 * and an activity's `stage_snapshot`, `from` and `to`: a stage with its board,
 * or a list of members. A status change's `from` and `to` are statuses.
 */
function referencedIds(item: FeedItem): string[] {
  const object = item.object ?? {}
  const ids: unknown[] = [object.board, object.stage, object.service, object.assignee]
  if (Array.isArray(object.assignees)) ids.push(...object.assignees)
  if (item.kind === 'activity') {
    for (const side of [object.from, object.to, object.stage_snapshot]) {
      if (Array.isArray(side)) {
        ids.push(...side)
      }
      else if (side && typeof side === 'object') {
        const { stage, board } = side as { stage?: unknown, board?: unknown }
        ids.push(stage, board)
      }
    }
  }
  return ids.filter((value): value is string => typeof value === 'string')
}

export class MemorySyncStore implements SyncStore {
  /** team → record key → the latest item applied. */
  rows = new Map<string, Map<string, FeedItem>>()
  cursors = new Map<string, string>()
  /** team → record key → version held, removals (tombstones) included. */
  versions = new Map<string, Map<string, number>>()
  /** `team/lead` → the lead's contact permissions. */
  permissions = new Map<string, Record<string, unknown>>()
  dictionaries = new Map<string, Record<string, unknown[]>>()
  dictionaryTimes = new Map<string, number>()
  /** team → keys the feed delivered during a running full resync. */
  recovery = new Map<string, Set<string>>()
  /** `team/lead` of every removed lead, so a facet or activity replayed later is not stored again. */
  removedLeads = new Set<string>()
  /** team → ids still unknown right after a dictionary refresh: departed members, deleted boards, stages or services. */
  unresolved = new Map<string, Set<string>>()

  async teamIds() {
    return [...this.rows.keys()]
  }

  async ensureTeam(team: string) {
    if (!this.rows.has(team)) this.rows.set(team, new Map())
    if (!this.versions.has(team)) this.versions.set(team, new Map())
  }

  async cursor(team: string) {
    return this.cursors.get(team) ?? null
  }

  async version(team: string, kind: string, id: string) {
    return this.versions.get(team)?.get(`${kind}/${id}`) ?? 0
  }

  async setContactPermissions(team: string, lead: string, permissions: Record<string, unknown>) {
    this.permissions.set(`${team}/${lead}`, structuredClone(permissions))
  }

  async upsert(team: string, item: FeedItem) {
    await this.ensureTeam(team)
    this.versions.get(team)!.set(keyOf(item), item.version)
    // A facet or an activity of a removed lead keeps its version but is not stored.
    const lead = item.kind === 'lead_attribution' || item.kind === 'lead_response' ? item.id : item.object?.lead
    if (typeof lead === 'string' && this.removedLeads.has(`${team}/${lead}`)) return
    this.rows.get(team)!.set(keyOf(item), structuredClone(item))
  }

  async remove(team: string, item: FeedItem) {
    await this.ensureTeam(team)
    // The tombstone: the version stays, so an older copy delivered later is ignored.
    this.versions.get(team)!.set(keyOf(item), item.version)
    const rows = this.rows.get(team)!
    rows.delete(keyOf(item))
    if (item.kind !== 'lead') return
    // A removed lead takes its attribution, response, activities and permissions with it.
    this.removedLeads.add(`${team}/${item.id}`)
    this.permissions.delete(`${team}/${item.id}`)
    for (const [key, row] of rows) {
      const facet = (row.kind === 'lead_attribution' || row.kind === 'lead_response') && row.id === item.id
      const activity = row.kind === 'activity' && row.object?.lead === item.id
      if (facet || activity) rows.delete(key)
    }
  }

  async saveCursor(team: string, cursor: string) {
    this.cursors.set(team, cursor)
  }

  async startResync(team: string) {
    this.versions.set(team, new Map())
    this.cursors.delete(team)
    this.recovery.set(team, new Set())
    this.unresolved.delete(team)
    // A restore can reissue ids and versions, so the resync starts from a clean slate.
    for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key)
  }

  async markSeen(team: string, item: FeedItem) {
    this.recovery.get(team)?.add(keyOf(item))
  }

  async finishResync(team: string) {
    const seen = this.recovery.get(team)
    if (!seen) return
    const rows = this.rows.get(team) ?? new Map<string, FeedItem>()
    for (const [key, row] of rows) {
      if (seen.has(key)) continue
      rows.delete(key)
      if (row.kind === 'lead') this.permissions.delete(`${team}/${row.id}`)
    }
    this.recovery.delete(team)
  }

  async dropTeam(team: string) {
    this.rows.delete(team)
    this.cursors.delete(team)
    this.versions.delete(team)
    this.recovery.delete(team)
    this.dictionaries.delete(team)
    this.dictionaryTimes.delete(team)
    this.unresolved.delete(team)
    for (const key of this.permissions.keys()) if (key.startsWith(`${team}/`)) this.permissions.delete(key)
    for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key)
  }

  async dictionariesAt(team: string) {
    return this.dictionaryTimes.get(team) ?? null
  }

  async saveDictionaries(team: string, data: Record<string, unknown[]>, at: number) {
    this.dictionaries.set(team, structuredClone(data))
    this.dictionaryTimes.set(team, at)
  }

  async hasUnknownReferences(team: string, item: FeedItem) {
    const unresolved = this.unresolved.get(team)
    return this.unknownIds(team, item).some(id => !unresolved?.has(id))
  }

  async rememberUnresolved(team: string, item: FeedItem) {
    const ids = this.unknownIds(team, item)
    if (!ids.length) return
    const unresolved = this.unresolved.get(team) ?? new Set<string>()
    for (const id of ids) unresolved.add(id)
    this.unresolved.set(team, unresolved)
  }

  /** The board, stage, service and member ids the item names that the dictionaries do not hold. */
  private unknownIds(team: string, item: FeedItem): string[] {
    if (!item.object) return []
    const known = new Set<string>()
    for (const entry of Object.values(this.dictionaries.get(team) ?? {}).flat()) {
      const object = entry as { id: string, stages?: Array<{ id: string }> }
      known.add(object.id)
      for (const stage of object.stages ?? []) known.add(stage.id)
    }
    return referencedIds(item).filter(id => !known.has(id))
  }
}

API version 1.0a