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=1000scope=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
| Field | Use it for |
|---|---|
id | The contact's permanent Bellwork id. Upsert on this. |
district_nces_id, school_nces_id | NCES ids of where they work. Match to your district and school accounts. |
district_id, school_id | Bellwork UUIDs for the same places, for GET /districts/{id} and friends. |
name, title, email, phone, linkedin_url | The person. |
is_district_contact | true for district-office staff, false for school-level staff. |
email_verification_status, email_bounced | Whether the email is safe to send to. |
record_status | active, 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_at | When one of the fields above last changed. |
notes, tags, added_via, overridden_fields | With 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: thepagination.next_cursorfrom 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_sinceset an hour or so before your last successful run. Upserts byidmake 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'snces_idnext 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.