Read what changed
List withupdated_since set to the moment your previous run started, and
sort=updated_at:
updated_at moves whenever anything about the record changes — a built-in
field, a custom field, or its tags. Timestamps must carry a timezone offset;
2026-09-30T00:00:00 without one is a 400.
Follow the cursor to the end:
updated_at.
Narrow a list with query parameters — repeat one to OR its values, combine
different ones to AND them:
POST /v1/contacts/search, which returns the same objects.
Read what was deleted
Deletions are a feed of their own, oldest first:contacts:read.
DELETE /v1/contacts/{id} (and the same on organizations and deals) removes a
record from every list and lookup without erasing anything: its
conversations and history are kept. Upserting the same contact again, or a new
message from them, restores it, and it reappears in your next updated_since
read.
Use your own ids
Setexternal_id to your system’s id when you create or update a record. It is
unique per workspace and resource, comes back on every read, and can be used as
a filter:
POST /v1/contacts/upsert matches on external_id first, then on phone:
Write in bulk
POST /v1/contacts/batch upserts up to 100 contacts in one call. Each item is
written exactly like POST /v1/contacts/upsert, in order, and gets its own
result — one failing item never undoes the others:
200 whenever the batch itself was valid; read each item’s
status. Send an Idempotency-Key header so a retried batch is not applied
twice.
Putting it together
- Record the time, then read changes with
updated_sinceset to your previous run’s start time. - Read
GET /v1/deleted-recordswithsinceset to the same time, and remove those records on your side. - Push your own changes with
POST /v1/contacts/batch, keyed byexternal_id, with anIdempotency-Key. - Store the time from step 1 as the start of this run.
X-RateLimit-Remaining on every response and slow down before you reach
zero.