Bellwork

Syncing contacts to a CRM

Pull Bellwork contacts into your own CRM and keep them current

Your CRM pulls from Bellwork on a schedule. The first run copies every contact you care about. Each run after that asks only for what changed since the last one. Bellwork never writes to your CRM; you decide what to keep.

If you build tables in Bellwork, start with Your own CRM: it pulls a table as accounts with their people, the way Salesforce gets it. This page is the other way in: people on their own, across every table, and only the ones that changed since your last run.

Which contacts

Most CRMs want your organization's contacts, not the whole catalog:

GET /api/v1/contacts?scope=org&updated_since=2000-01-01&limit=1000

scope=org is everyone your organization has worked with in Bellwork:

  • everyone you have a contact record for: people you revealed, added, enriched, or emailed from your mailbox;
  • everyone named on one of your open tables, such as the person a "Superintendent" or "HR Director" column found for each district.

Each row comes with your organization's layer: your notes and tags, how the person got there (added_via), and any field you edited by hand (overridden_fields, with your value in the field itself). In sync mode a change on your side counts as a change too: edit a note, or re-run a table column, and that person comes back on the next pull.

Leave scope out to sync from the whole catalog instead, narrowed by your filters (state, title, district).

People you added by hand who aren't in Bellwork's directory aren't in /contacts, because they have no Bellwork contact id. GET /api/v1/org-contacts lists every record, those included, each with the person as your organization sees them.

What to store

FieldUse it for
idThe contact's permanent Bellwork id. Upsert on this.
district_nces_id, school_nces_idNCES ids of where they work. Match to your district and school accounts.
district_id, school_idBellwork UUIDs for the same places, for GET /districts/{id} and friends.
name, title, email, phone, linkedin_urlThe person.
is_district_contacttrue for district-office staff, false for school-level staff.
email_verification_status, email_bouncedWhether the email is safe to send to.
record_statusactive, stale (no longer listed where we found them), or superseded (replaced by a newer record for the same person). Mark these in your CRM; don't delete them.
last_updated_atWhen one of the fields above last changed.
notes, tags, added_via, overridden_fieldsWith scope=org: your organization's layer on this person.

last_updated_at moves when a field a CRM keeps changes: name, title, email, phone, LinkedIn, workplace, record_status, or email verification. It does not move for Bellwork's own processing.

The sync loop

Every sync request is GET /api/v1/contacts with your filters plus one of:

  • updated_since: an ISO 8601 instant or date. Starts a sync from that point.
  • cursor: the pagination.next_cursor from the page before. Continues it.

Rows come back oldest change first, limit per page (up to 1,000 in sync mode). pagination.has_more says whether to ask again. pagination.total is null in sync mode: counting the whole catalog on every page would only slow you down.

With scope=org, a person is "changed" when Bellwork's record changes or your side does (a note, a tag, an edit, a table row that names them).

Keep the same filters on every page of a run. offset, unique=true, and project_table_id are rejected in sync mode. Sync mode always returns records with permanent ids.

# First page: everything in Texas and Arizona that changed since 1 Oct.
curl -H "Authorization: Bearer $BELLWORK_API_KEY" \
  'https://www.bellwork.ai/api/v1/contacts?state=TX,AZ&title_any=superintendent|counsel&updated_since=2026-10-01T00:00:00Z&limit=1000'

# Next page: same filters, the cursor instead of updated_since.
curl -H "Authorization: Bearer $BELLWORK_API_KEY" \
  'https://www.bellwork.ai/api/v1/contacts?state=TX,AZ&title_any=superintendent|counsel&cursor=eyJ0cyI6...&limit=1000'
{
  "data": [
    {
      "id": "57c9f6c6-f4ef-48c7-a5ae-69337b36a2fd",
      "name": "…",
      "title": "Director of Counseling",
      "email": "…",
      "district_id": "…",
      "district_nces_id": "4816110",
      "school_id": null,
      "school_nces_id": null,
      "record_status": "active",
      "last_updated_at": "2026-10-01T14:36:59.325Z"
    }
  ],
  "pagination": {
    "total": null,
    "limit": 1000,
    "offset": 0,
    "has_more": true,
    "next_cursor": "eyJ0cyI6IjIwMjYtMTAtMDEgMTQ6MzY6NTkuMzI1NTc2KzAwIiwiaWQiOiI1N2M5..."
  }
}

A complete job

This TypeScript runs as a scheduled job (a cron route, a worker, anything server-side). It stores one value between runs, the last cursor, and upserts by id.

const BASE = "https://www.bellwork.ai/api/v1/contacts";
// Your organization's contacts. For the catalog instead:
// { state: "TX,AZ,MI", title_any: "superintendent|counsel|curriculum" }
const FILTERS: Record<string, string> = { scope: "org" };

export async function syncBellworkContacts(
  store: { getCursor(): Promise<string | null>; setCursor(c: string): Promise<void> },
  upsert: (contacts: BellworkContact[]) => Promise<void>,
) {
  let cursor = await store.getCursor();
  for (;;) {
    const params = new URLSearchParams({ ...FILTERS, limit: "1000" });
    // First run ever: start from the beginning of time (a full backfill).
    if (cursor) params.set("cursor", cursor);
    else params.set("updated_since", "2000-01-01");

    const res = await fetch(`${BASE}?${params}`, {
      headers: { Authorization: `Bearer ${process.env.BELLWORK_API_KEY}` },
    });
    if (res.status === 429 || res.status >= 500) {
      // Back off and retry the same request; the cursor hasn't moved.
      await new Promise((r) => setTimeout(r, 2_000 + Math.random() * 3_000));
      continue;
    }
    if (!res.ok) throw new Error(`Bellwork ${res.status}: ${await res.text()}`);

    const body = await res.json();
    await upsert(body.data); // keyed on contact.id
    if (body.pagination.next_cursor) {
      cursor = body.pagination.next_cursor;
      await store.setCursor(cursor!); // save after every page: a crash resumes here
    }
    if (!body.pagination.has_more) return;
  }
}

type BellworkContact = {
  id: string;
  name: string;
  title: string | null;
  email: string | null;
  phone: string | null;
  linkedin_url: string | null;
  district_id: string | null;
  district_nces_id: string | null;
  school_id: string | null;
  school_nces_id: string | null;
  is_district_contact: boolean;
  email_verification_status: string | null;
  email_bounced: boolean;
  record_status: "active" | "stale" | "superseded";
  last_updated_at: string;
  // scope=org: your organization's layer (absent for someone you never touched)
  notes?: string | null;
  tags?: string[];
  added_via?: string;
  overridden_fields?: string[];
};

When a run finds nothing new, the last page is empty and hands your cursor back, so the next run picks up from the same place.

Changing your filters (adding a state, say) means starting a new sync with updated_since. A cursor only continues the run that issued it.

Belt and braces

  • Overlap after an outage. If your job was down for a while, or you just want certainty, run once with updated_since set an hour or so before your last successful run. Upserts by id make re-reading rows harmless.
  • Re-check one person. GET /contacts/{id} returns a single contact in the same shape, NCES ids included.
  • Only some districts. Add district_ids= (comma-separated UUIDs) to sync just your accounts. GET /districts?state=… returns each district's nces_id next to its Bellwork id, so you can map your accounts once.
  • How long a backfill takes. Each page takes about a second. Superintendent, counseling, and curriculum titles across six states are roughly 45,000 people: about 45 pages of 1,000. After that, each run reads only what changed and is usually one or two pages.
  • Rate limits. Your plan allows 5,000 requests a minute, far more than a sync uses. See Rate limits.