> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chatsailer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Syncing

> Keep another system in step with a Sailer workspace: changes, deletions, your own ids, and bulk writes.

A two-way sync needs four things from an API: a way to read only what changed,
a way to learn what was deleted, a key you control, and a way to write many
records at once. Sailer has all four for contacts, organizations and deals.

## Read what changed

List with `updated_since` set to the moment your previous run **started**, and
`sort=updated_at`:

```bash theme={null}
curl "https://api.chatsailer.com/v1/contacts?updated_since=2026-09-30T00:00:00Z&sort=updated_at&limit=200" \
  -H "Authorization: Bearer $SAILER_API_TOKEN"
```

`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:

```python theme={null}
params = {"updated_since": last_run_started_at, "sort": "updated_at", "limit": 200}
while True:
    page = httpx.get(f"{BASE}/v1/contacts", params=params, headers=headers).json()
    for contact in page["data"]:
        upsert_locally(contact)
    if page["links"]["next"] is None:
        break
    params["cursor"] = page["links"]["next"]
```

Cursors are anchored on the last record you received, so a record created or
edited while you are paging is never served twice and never skipped. A record
edited mid-walk simply reappears later in the same walk, with its new
`updated_at`.

Narrow a list with query parameters — repeat one to OR its values, combine
different ones to AND them:

```bash theme={null}
# Won or lost contacts of one organization
curl "https://api.chatsailer.com/v1/contacts?status=won&status=lost&organization_id=org_..."
```

For nested conditions, ranges or filters across relationships, use
`POST /v1/contacts/search`, which returns the same objects.

## Read what was deleted

Deletions are a feed of their own, oldest first:

```bash theme={null}
curl "https://api.chatsailer.com/v1/deleted-records?resource=contact&since=2026-09-30T00:00:00Z" \
  -H "Authorization: Bearer $SAILER_API_TOKEN"
```

```json theme={null}
{
  "data": [
    {
      "object": "deleted_record",
      "resource": "contact",
      "id": "con_9f2ac41d8b7e4a51b0d3e6f7a1c2d3e4",
      "external_id": "crm-48213",
      "deleted_at": "2026-09-30T14:02:11Z"
    }
  ],
  "meta": { "limit": 50, "has_more": false },
  "links": { "next": null }
}
```

It lists records deleted through this API, in the Sailer app, or by a
connected CRM. It needs the resource's read scope, e.g. `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

Set `external_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:

```bash theme={null}
curl "https://api.chatsailer.com/v1/contacts?external_id=crm-48213"
```

`POST /v1/contacts/upsert` matches on `external_id` first, then on `phone`:

| What you send | What happens |
| - | - |
| An `external_id` no contact has, a new phone | A contact is created with your `external_id` (`201`) |
| An `external_id` a contact has, the same phone | That contact is updated (`200`) |
| A phone a contact has, no `external_id` on it yet | That contact is updated and given your `external_id` (`200`) |
| An `external_id` on one contact, a phone on another | `409 external_id_conflict`: the two keys disagree |
| An `external_id` on a contact with a different phone | `409 phone_mismatch`: upsert never changes a 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:

```json theme={null}
{
  "object": "batch_result",
  "data": [
    { "index": 0, "status": "created", "contact": { "object": "contact", "id": "con_..." } },
    { "index": 1, "status": "updated", "contact": { "object": "contact", "id": "con_..." } },
    {
      "index": 2,
      "status": "failed",
      "error": { "type": "invalid_request_error", "code": "validation_failed", "message": "..." }
    }
  ],
  "created": 1,
  "updated": 1,
  "failed": 1
}
```

The call answers `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

1. Record the time, then read changes with `updated_since` set to your previous
   run's start time.
2. Read `GET /v1/deleted-records` with `since` set to the same time, and remove
   those records on your side.
3. Push your own changes with `POST /v1/contacts/batch`, keyed by
   `external_id`, with an `Idempotency-Key`.
4. Store the time from step 1 as the start of this run.

Watch `X-RateLimit-Remaining` on every response and slow down before you reach
zero.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.