---
title: Two-way contact sync
description: Push contacts from another system with a webhook skip key, pull every contact changed since the last round, deletions included, and tell people's edits from integration writes.
canonical: https://developers.coevera.com/recipes/two-way-contact-sync/
section: recipes
verification: behaviour
lastVerified: 2026-10-15
publisher: Coevera
---

# Two-way contact sync

**Answer:** Write with a WebHook-Skip-Key header that your webhook lists in skip_keys, so your own writes do not come back. Pull contacts with filter[modified] gte the round's start, include-deleted and order-by modified. modified_by_user is set only by people's edits in Coevera apps: when it is empty or older than the round, skip the row.

Northwind keeps its contacts in a second system, and both sides change them. One sync round
pushes the other system's changes into Coevera, then pulls Coevera's changes back: here, a new
contact, **Priya Nair**, and a new phone number for her. The pull must not echo the round's own
writes, and must pass on what people changed in Coevera and what they deleted.

## Before you start

|                    |                                                                                                                                                                                                                 |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Permissions        | **Full Access** to **Contacts** on the **Records** tab of the application's permissions. The owner lookup also reads users (`Clients`).                                                                         |
| `validation-level` | The default (`0`, full validation).                                                                                                                                                                             |
| Batch behaviour    | No batches: every write is one record.                                                                                                                                                                          |
| Skip key           | Every write sends `WebHook-Skip-Key: northwind-sync`. A webhook whose `options.skip_keys` lists `northwind-sync` does not fire for those writes; see [Filtering and options](/webhooks/filtering-and-options/). |
| Context            | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`.                                                                                              |

Two signals keep the sync from echoing itself:

- **The skip key** stops your webhook from delivering your own writes. The recipe sends the
  header; its effect shows in your webhook's deliveries.
- **`modified_by_user`** (in the app, **Modified Date by User**) is the time a person last
  changed the record in a Coevera app. Writes through the API or Automatizer change `modified`
  but leave `modified_by_user` alone: it stays `null` after the program's writes. A pulled
  contact whose `modified_by_user` is empty or older than the round is not a person's edit.

The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`.
[Authentication](/get-started/authentication/) shows where the credentials come from, and
[Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions.

The operations this recipe calls, in the API reference: REST
[`GET /entities/Clients`](/reference/rest/clients/#list),
[`POST /entities/Contacts`](/reference/rest/contacts/#create),
[`PATCH /entities/Contacts/{id}`](/reference/rest/contacts/#update),
[`GET /entities/Contacts`](/reference/rest/contacts/#list); GraphQL
[`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria),
[`createContact`](/reference/graphql/contact/#m-createContact),
[`updateContact`](/reference/graphql/contact/#m-updateContact),
[`ContactRepository.getByCriteria`](/reference/graphql/contact/#q-getByCriteria).

## Set up a helper

Every step below uses this setup; it sends the skip key on every request. Export the credentials
first:

```bash
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
```

**REST · cURL**

```bash
COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
# Every write of the sync carries this key. A webhook that lists it in options.skip_keys does
# not fire for these writes, so the sync never receives its own changes back.
SKIP_KEY="northwind-sync"
```

**REST · TypeScript**

```ts
const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

// Every write of the sync carries this key. A webhook that lists it in options.skip_keys does
// not fire for these writes, so the sync never receives its own changes back.
const SKIP_KEY = "northwind-sync";

async function coevera(path: string, init: { method?: string; body?: unknown } = {}) {
  const res = await fetch(`${base}${path}`, {
    method: init.method ?? "GET",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", "WebHook-Skip-Key": SKIP_KEY },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${String(body.message).trim()}`);
  return body;
}
```

**REST · Python**

```python
BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])
# Every write of the sync carries this key. A webhook that lists it in options.skip_keys does
# not fire for these writes, so the sync never receives its own changes back.
session.headers["WebHook-Skip-Key"] = "northwind-sync"


def coevera(method, path, **kwargs):
    response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs)
    body = response.json()
    if not response.ok:
        sys.exit(f"HTTP {response.status_code} {body.get('name')}: {str(body.get('message')).strip()}")
    return body


def utc(value):
    """A date-time from the API as an aware datetime: the API answers in UTC unless asked otherwise."""
    parsed = datetime.fromisoformat(value)
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
```

**GraphQL · cURL**

```bash
COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public"
# Every write of the sync carries this key. A webhook that lists it in options.skip_keys does
# not fire for these writes, so the sync never receives its own changes back.
SKIP_KEY="northwind-sync"

# gql: POST the GraphQL request read from stdin. GraphQL reports errors in the body, often
# with HTTP 200, so fail on those too.
gql() {
  local response
  response=$(curl -sS --fail-with-body "$COEVERA_GRAPHQL" \
    -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
    -H "Content-Type: application/json" \
    -H "WebHook-Skip-Key: $SKIP_KEY" \
    -d @-)
  if jq -e '.errors' <<<"$response" >/dev/null; then
    echo "$response" >&2
    return 1
  fi
  echo "$response"
}
```

**GraphQL · TypeScript**

```ts
const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

// Every write of the sync carries this key. A webhook that lists it in options.skip_keys does
// not fire for these writes, so the sync never receives its own changes back.
const SKIP_KEY = "northwind-sync";

// gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200.
async function gql(query: string, variables: object = {}) {
  const res = await fetch(endpoint, {
    method: "POST",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", "WebHook-Skip-Key": SKIP_KEY },
    body: JSON.stringify({ query, variables }),
  });
  const body = await res.json();
  if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`);
  return body.data;
}
```

**GraphQL · Python**

```python
ENDPOINT = (
    "https://{region}.api.coevera.com/api/v100/app/space/"
    "{space_id}/graphql/public"
)
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])
# Every write of the sync carries this key. A webhook that lists it in options.skip_keys does
# not fire for these writes, so the sync never receives its own changes back.
session.headers["WebHook-Skip-Key"] = "northwind-sync"


def gql(query, variables=None):
    """One GraphQL request. GraphQL reports errors in the body, often with HTTP 200."""
    response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30)
    body = response.json()
    if not response.ok or body.get("errors"):
        sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}")
    return body["data"]


def utc(value):
    """A date-time from the API as an aware datetime: the API answers in UTC unless asked otherwise."""
    parsed = datetime.fromisoformat(value)
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
```

## 1. Find the owner

A new contact needs an owner. Look the sales rep up by e-mail.

**REST · cURL**

```bash
owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \
  --data-urlencode "filter[email]=dana.whitfield@example.com" \
  -d load-only=id,name \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$owner"
OWNER_ID=$(jq -er '.data[0].id' <<<"$owner")
```

**REST · TypeScript**

```ts
const owners = await coevera(
  `/entities/Clients?${new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name" })}`,
);
const ownerId: string | undefined = owners.data[0]?.id;
if (!ownerId) throw new Error("No user with that e-mail in this space.");
console.log("Owner:", owners.data[0].name, ownerId);
```

**REST · Python**

```python
owners = coevera(
    "GET",
    "/entities/Clients",
    params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"},
)
if not owners["data"]:
    sys.exit("No user with that e-mail in this space.")
owner_id = owners["data"][0]["id"]
print("Owner:", owners["data"][0]["name"], owner_id)
```

**GraphQL · cURL**

```bash
owner=$(jq -n '{
  query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }"
}' | gql)
echo "$owner"
OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner")
```

**GraphQL · TypeScript**

```ts
const owners = await gql(`{
  entities {
    client {
      getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
        edges { node { id name } }
      }
    }
  }
}`);
const ownerId: string | undefined = owners.entities.client.getByCriteria.edges[0]?.node.id;
if (!ownerId) throw new Error("No user with that e-mail in this space.");
console.log("Owner:", owners.entities.client.getByCriteria.edges[0].node.name, ownerId);
```

**GraphQL · Python**

```python
owners = gql("""
{
  entities {
    client {
      getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
        edges { node { id name } }
      }
    }
  }
}
""")
edges = owners["entities"]["client"]["getByCriteria"]["edges"]
if not edges:
    sys.exit("No user with that e-mail in this space.")
owner_id = edges[0]["node"]["id"]
print("Owner:", edges[0]["node"]["name"], owner_id)
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "total": 1,
  "page_info": {
    "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "has_previous_page": false,
    "has_next_page": false
  },
  "data": [
    {
      "id": "0d000000-0000-4000-a000-000000000001",
      "name": "Dana Whitfield"
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "client": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0d000000-0000-4000-a000-000000000001",
                "name": "Dana Whitfield"
              }
            }
          ]
        }
      }
    }
  }
}
```

_Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._

## 2. Push a new contact

The round starts here: the program keeps the time, a minute back to allow for clock drift, as
the round's start. A real sync stores it, and the next round pulls from the newest `modified` it
has seen. Then it creates the contact the other system added.

**REST · cURL**

```bash
# The start of this round, in UTC. A real sync stores it and starts the next round from it.
SINCE=$(date -u -d '1 minute ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-1M +%Y-%m-%dT%H:%M:%SZ)

# A contact that is new in the other system.
contact=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Contacts" \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
  -H "Content-Type: application/json" \
  -H "WebHook-Skip-Key: $SKIP_KEY" \
  -d @- <<JSON
{ "first_name": "Priya", "last_name": "Nair", "email1": "priya.nair@example.com", "owner_id": "$OWNER_ID" }
JSON
)
echo "$contact"
CONTACT_ID=$(jq -er '.data.id' <<<"$contact")
```

**REST · TypeScript**

```ts
// The start of this round. A real sync stores it and starts the next round from it.
const since = new Date(Date.now() - 60_000).toISOString();

// A contact that is new in the other system.
const created = await coevera("/entities/Contacts", {
  method: "POST",
  body: { first_name: "Priya", last_name: "Nair", email1: "priya.nair@example.com", owner_id: ownerId },
});
const contactId: string = created.data.id;
console.log("Pushed:", contactId);
```

**REST · Python**

```python
# The start of this round. A real sync stores it and starts the next round from it.
since = datetime.now(timezone.utc) - timedelta(minutes=1)

# A contact that is new in the other system.
created = coevera(
    "POST",
    "/entities/Contacts",
    json={"first_name": "Priya", "last_name": "Nair", "email1": "priya.nair@example.com", "owner_id": owner_id},
)
contact_id = created["data"]["id"]
print("Pushed:", contact_id)
```

**GraphQL · cURL**

```bash
# The start of this round, in UTC. A real sync stores it and starts the next round from it.
SINCE=$(date -u -d '1 minute ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-1M +%Y-%m-%dT%H:%M:%SZ)

# A contact that is new in the other system.
contact=$(jq -n --arg owner "$OWNER_ID" '{
  query: "mutation PushNew($input: CreateContactInput!) { createContact(input: $input) { contact { id } } }",
  variables: { input: { firstName: "Priya", lastName: "Nair", email1: "priya.nair@example.com", ownerId: $owner } }
}' | gql)
echo "$contact"
CONTACT_ID=$(jq -er '.data.createContact.contact.id' <<<"$contact")
```

**GraphQL · TypeScript**

```ts
// The start of this round. A real sync stores it and starts the next round from it.
const since = new Date(Date.now() - 60_000).toISOString();

// A contact that is new in the other system.
const created = await gql(
  `mutation PushNew($input: CreateContactInput!) {
    createContact(input: $input) { contact { id } }
  }`,
  { input: { firstName: "Priya", lastName: "Nair", email1: "priya.nair@example.com", ownerId } },
);
const contactId: string = created.createContact.contact.id;
console.log("Pushed:", contactId);
```

**GraphQL · Python**

```python
# The start of this round. A real sync stores it and starts the next round from it.
since = datetime.now(timezone.utc) - timedelta(minutes=1)

# A contact that is new in the other system.
created = gql(
    """
    mutation PushNew($input: CreateContactInput!) {
      createContact(input: $input) { contact { id } }
    }
    """,
    {"input": {"firstName": "Priya", "lastName": "Nair", "email1": "priya.nair@example.com", "ownerId": owner_id}},
)
contact_id = created["createContact"]["contact"]["id"]
print("Pushed:", contact_id)
```

**Recorded response · REST · 201 Created**

```json
{
  "success": true,
  "data": {
    "is_delete_protected": false,
    "id": "0c000000-0000-4000-a000-000000000001",
    "is_deleted": false,
    "modified": "2026-10-15 09:00:58.836143+00:00",
    "created": "2026-10-15 09:00:58.836195+00:00",
    "contact_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ContactTypes/00000000-0000-4000-a000-000000000001",
    "currency_exchange_rates_list": null,
    "owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001",
    "picture": null,
    "unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001",
    "address": "",
    "city": "",
    "comments": "",
    "contact_type_id": "00000000-0000-4000-a000-000000000001",
    "country": "",
    "currency_exchange_rates_list_id": null,
    "email1": "priya.nair@example.com",
    "email2": "",
    "email3": "",
    "email4": "",
    "email5": "",
    "first_name": "Priya",
    "gender": 0,
    "is_archived": false,
    "last_name": "Nair",
    "middle_name": "",
    "owner_id": "0d000000-0000-4000-a000-000000000001",
    "phone1": "",
    "phone2": "",
    "phone3": "",
    "phone4": "",
    "phone5": "",
    "picture_id": null,
    "position": "",
    "quick_account_name": "",
    "share_mode": 0,
    "state_province": "",
    "title": "",
    "unit_id": "05000000-0000-4000-a000-000000000001",
    "zip_code": "",
    "revision": 1244,
    "formatted_name": "Priya Nair",
    "social_media": null,
    "primary_account": null,
    "account_relations": [],
    "sharing_units": [],
    "sharing_clients": [],
    "documents": [],
    "exchange_entity_id": null,
    "gapi_entity_id": null,
    "o365_entity_id": null,
    "is_favorite": false,
    "primary_account_account_roles": null,
    "primary_account_position": null,
    "account_position": "",
    "is_unsubscribed": false,
    "tags": [],
    "static_profiles": [],
    "primary_account_relationship": null,
    "modified_by_user": null
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "createContact": {
      "contact": {
        "id": "0c000000-0000-4000-a000-000000000001"
      }
    }
  }
}
```

_Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._

## 3. Push a change

The other system changed Priya's phone number.

**REST · cURL**

```bash
# A change made in the other system.
curl -sS --fail-with-body -X PATCH "$COEVERA_BASE/entities/Contacts/$CONTACT_ID" \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
  -H "Content-Type: application/json" \
  -H "WebHook-Skip-Key: $SKIP_KEY" \
  -d '{ "phone1": "+1 206 555 0141" }' >/dev/null
echo "Pushed the new phone number."
```

**REST · TypeScript**

```ts
// A change made in the other system.
await coevera(`/entities/Contacts/${contactId}`, { method: "PATCH", body: { phone1: "+1 206 555 0141" } });
console.log("Pushed the new phone number.");
```

**REST · Python**

```python
# A change made in the other system.
coevera("PATCH", f"/entities/Contacts/{contact_id}", json={"phone1": "+1 206 555 0141"})
print("Pushed the new phone number.")
```

**GraphQL · cURL**

```bash
# A change made in the other system.
jq -n --arg id "$CONTACT_ID" '{
  query: "mutation PushChange($input: UpdateContactInput!) { updateContact(input: $input) { contact { id phone1 } } }",
  variables: { input: { id: $id, phone1: "+1 206 555 0141" } }
}' | gql
```

**GraphQL · TypeScript**

```ts
// A change made in the other system.
await gql(
  `mutation PushChange($input: UpdateContactInput!) {
    updateContact(input: $input) { contact { id phone1 } }
  }`,
  { input: { id: contactId, phone1: "+1 206 555 0141" } },
);
console.log("Pushed the new phone number.");
```

**GraphQL · Python**

```python
# A change made in the other system.
gql(
    """
    mutation PushChange($input: UpdateContactInput!) {
      updateContact(input: $input) { contact { id phone1 } }
    }
    """,
    {"input": {"id": contact_id, "phone1": "+1 206 555 0141"}},
)
print("Pushed the new phone number.")
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "data": {
    "is_delete_protected": false,
    "id": "0c000000-0000-4000-a000-000000000001",
    "is_deleted": false,
    "modified": "2026-10-15 09:00:59.383553+00:00",
    "created": "2026-10-15 09:00:58.836195+00:00",
    "contact_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ContactTypes/00000000-0000-4000-a000-000000000001",
    "currency_exchange_rates_list": null,
    "owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001",
    "picture": null,
    "unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001",
    "address": "",
    "city": "",
    "comments": "",
    "contact_type_id": "00000000-0000-4000-a000-000000000001",
    "country": "",
    "currency_exchange_rates_list_id": null,
    "email1": "priya.nair@example.com",
    "email2": "",
    "email3": "",
    "email4": "",
    "email5": "",
    "first_name": "Priya",
    "gender": 0,
    "is_archived": false,
    "last_name": "Nair",
    "middle_name": "",
    "owner_id": "0d000000-0000-4000-a000-000000000001",
    "phone1": "+1 206 555 0141",
    "phone2": "",
    "phone3": "",
    "phone4": "",
    "phone5": "",
    "picture_id": null,
    "position": "",
    "quick_account_name": "",
    "share_mode": 0,
    "state_province": "",
    "title": "",
    "unit_id": "05000000-0000-4000-a000-000000000001",
    "zip_code": "",
    "revision": 1245,
    "formatted_name": "Priya Nair",
    "social_media": null,
    "primary_account": null,
    "account_relations": [],
    "sharing_units": [],
    "sharing_clients": [],
    "documents": [],
    "exchange_entity_id": null,
    "gapi_entity_id": null,
    "o365_entity_id": null,
    "is_favorite": false,
    "primary_account_account_roles": null,
    "primary_account_position": null,
    "account_position": "",
    "is_unsubscribed": false,
    "tags": [],
    "static_profiles": [],
    "primary_account_relationship": null,
    "modified_by_user": null
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "updateContact": {
      "contact": {
        "id": "0c000000-0000-4000-a000-000000000001",
        "phone1": "+1 206 555 0141"
      }
    }
  }
}
```

_Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._

## 4. Pull what changed

Ask for every contact with `modified` at or after the round's start (`filter-op[modified]=gte`),
oldest first, with `include-deleted=true` so that deletions come too. Send the date encoded: a
raw `+` in an offset would arrive as a space. Then decide per contact:

| The pulled contact                               | What to do on your side                                |
| ------------------------------------------------ | ------------------------------------------------------ |
| `is_deleted: true`                               | Delete it.                                             |
| `modified_by_user` at or after the round's start | A person changed it in Coevera: apply the change.      |
| `modified_by_user` empty or older                | Written by an integration, this one included: skip it. |

**REST · cURL**

```bash
# Everything changed since the round started, deletions included, oldest first.
changes=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Contacts" \
  --data-urlencode "filter[modified]=$SINCE" \
  -d "filter-op[modified]=gte" -d include-deleted=true -d order-by=modified -d first=100 \
  -d load-only=id,first_name,last_name,email1,phone1,modified,modified_by_user,is_deleted \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$changes"
# modified_by_user is when a person last changed the record in a Coevera app; API and
# Automatizer writes leave it alone. Older than this round: not a person's edit.
jq -r --arg since "$SINCE" '.data[]
  | (if .is_deleted then "delete on your side"
     elif .modified_by_user != null and (.modified_by_user | sub(" "; "T") | .[0:19]) >= ($since | .[0:19]) then "apply to your side"
     else "skip: written by an integration" end) as $action
  | "\(.first_name) \(.last_name) <\(.email1)>: \($action)"' <<<"$changes"
```

**REST · TypeScript**

```ts
// Everything changed since the round started, deletions included, oldest first. The date goes
// through URLSearchParams: a raw "+" in an offset would arrive as a space.
const changes = await coevera(
  `/entities/Contacts?${new URLSearchParams({
    "filter[modified]": since,
    "filter-op[modified]": "gte",
    "include-deleted": "true",
    "order-by": "modified",
    first: "100",
    "load-only": "id,first_name,last_name,email1,phone1,modified,modified_by_user,is_deleted",
  })}`,
);
for (const c of changes.data) {
  // modified_by_user is when a person last changed the record in a Coevera app; API and
  // Automatizer writes leave it alone. Older than this round: not a person's edit.
  const byPerson = c.modified_by_user !== null && Date.parse(c.modified_by_user.replace(" ", "T")) >= Date.parse(since);
  const action = c.is_deleted ? "delete on your side" : byPerson ? "apply to your side" : "skip: written by an integration";
  console.log(`${c.first_name} ${c.last_name} <${c.email1}>: ${action}`);
}
```

**REST · Python**

```python
# Everything changed since the round started, deletions included, oldest first. requests
# encodes the date: a raw "+" in an offset would arrive as a space.
changes = coevera(
    "GET",
    "/entities/Contacts",
    params={
        "filter[modified]": since.isoformat(),
        "filter-op[modified]": "gte",
        "include-deleted": "true",
        "order-by": "modified",
        "first": 100,
        "load-only": "id,first_name,last_name,email1,phone1,modified,modified_by_user,is_deleted",
    },
)
for c in changes["data"]:
    # modified_by_user is when a person last changed the record in a Coevera app; API and
    # Automatizer writes leave it alone. Older than this round: not a person's edit.
    by_person = c["modified_by_user"] is not None and utc(c["modified_by_user"]) >= since
    if c["is_deleted"]:
        action = "delete on your side"
    elif by_person:
        action = "apply to your side"
    else:
        action = "skip: written by an integration"
    print(f"{c['first_name']} {c['last_name']} <{c['email1']}>: {action}")
```

**GraphQL · cURL**

```bash
# Everything changed since the round started, deletions included, oldest first.
changes=$(jq -n --arg since "$SINCE" '{
  query: "query Pull($since: DateTime!) { entities { contact { getByCriteria(first: 100, includeDeleted: true, filter: { modified: { gte: $since } }, orderBy: [{ modified: Asc }]) { edges { node { id firstName lastName email1 phone1 modified modifiedByUser isDeleted } } } } } }",
  variables: { since: $since }
}' | gql)
echo "$changes"
# modifiedByUser is when a person last changed the record in a Coevera app; API and
# Automatizer writes leave it alone. Older than this round: not a person's edit.
jq -r --arg since "$SINCE" '.data.entities.contact.getByCriteria.edges[].node
  | (if .isDeleted then "delete on your side"
     elif .modifiedByUser != null and (.modifiedByUser | .[0:19]) >= ($since | .[0:19]) then "apply to your side"
     else "skip: written by an integration" end) as $action
  | "\(.firstName) \(.lastName) <\(.email1)>: \($action)"' <<<"$changes"
```

**GraphQL · TypeScript**

```ts
// Everything changed since the round started, deletions included, oldest first.
const changes = await gql(
  `query Pull($since: DateTime!) {
    entities {
      contact {
        getByCriteria(first: 100, includeDeleted: true, filter: { modified: { gte: $since } }, orderBy: [{ modified: Asc }]) {
          edges { node { id firstName lastName email1 phone1 modified modifiedByUser isDeleted } }
        }
      }
    }
  }`,
  { since },
);
for (const { node: c } of changes.entities.contact.getByCriteria.edges) {
  // modifiedByUser is when a person last changed the record in a Coevera app; API and
  // Automatizer writes leave it alone. Older than this round: not a person's edit.
  const byPerson = c.modifiedByUser !== null && Date.parse(c.modifiedByUser) >= Date.parse(since);
  const action = c.isDeleted ? "delete on your side" : byPerson ? "apply to your side" : "skip: written by an integration";
  console.log(`${c.firstName} ${c.lastName} <${c.email1}>: ${action}`);
}
```

**GraphQL · Python**

```python
# Everything changed since the round started, deletions included, oldest first.
changes = gql(
    """
    query Pull($since: DateTime!) {
      entities {
        contact {
          getByCriteria(
            first: 100
            includeDeleted: true
            filter: { modified: { gte: $since } }
            orderBy: [{ modified: Asc }]
          ) {
            edges { node { id firstName lastName email1 phone1 modified modifiedByUser isDeleted } }
          }
        }
      }
    }
    """,
    {"since": since.isoformat()},
)
for edge in changes["entities"]["contact"]["getByCriteria"]["edges"]:
    c = edge["node"]
    # modifiedByUser is when a person last changed the record in a Coevera app; API and
    # Automatizer writes leave it alone. Older than this round: not a person's edit.
    by_person = c["modifiedByUser"] is not None and utc(c["modifiedByUser"]) >= since
    if c["isDeleted"]:
        action = "delete on your side"
    elif by_person:
        action = "apply to your side"
    else:
        action = "skip: written by an integration"
    print(f"{c['firstName']} {c['lastName']} <{c['email1']}>: {action}")
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "total": 1,
  "page_info": {
    "start_cursor": "WyIyMDI2LTEwLTE1VDEwOjQ2OjQzLjk3MDU1MyswMDowMCIsICIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "end_cursor": "WyIyMDI2LTEwLTE1VDEwOjQ2OjQzLjk3MDU1MyswMDowMCIsICIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "has_previous_page": false,
    "has_next_page": false
  },
  "data": [
    {
      "id": "0c000000-0000-4000-a000-000000000001",
      "is_deleted": false,
      "modified": "2026-10-15 09:00:59.383553+00:00",
      "email1": "priya.nair@example.com",
      "first_name": "Priya",
      "last_name": "Nair",
      "phone1": "+1 206 555 0141",
      "modified_by_user": null
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "contact": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0c000000-0000-4000-a000-000000000002",
                "firstName": "Priya",
                "lastName": "Nair",
                "email1": "priya.nair@example.com",
                "phone1": "+1 206 555 0141",
                "modified": "2026-10-15T09:00:00.000000+00:00",
                "modifiedByUser": null,
                "isDeleted": false
              }
            },
            {
              "node": {
                "id": "0c000000-0000-4000-a000-000000000001",
                "firstName": "Priya",
                "lastName": "Nair",
                "email1": "priya.nair@example.com",
                "phone1": "+1 206 555 0141",
                "modified": "2026-10-15T09:00:01.389544+00:00",
                "modifiedByUser": null,
                "isDeleted": false
              }
            }
          ]
        }
      }
    }
  }
}
```

_Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._

The pull returns every contact changed in the window, not only the round's own: in the GraphQL
recording, the first row is the contact the REST program had pushed a moment before.

## What to check

- Steps 2 and 3 answer **201** and **200** (GraphQL: 200).
- Step 4 returns Priya with the new `phone1`, `is_deleted: false` and `modified_by_user: null`
  (GraphQL `modifiedByUser`), so the program skips her: no echo.
- The rows come oldest `modified` first.
- Run the program twice and the second round pushes a second Priya Nair: the API does not refuse
  duplicates. A real sync keeps the Coevera id of every record it pushed and updates instead.

## Clean up

The program leaves the contact in your space. Delete it with
`POST /entities/Contacts/batch-delete`; the next pull with `include-deleted=true` reports it with
`is_deleted: true`, which is how your side learns of deletions.

## Limits

- One page holds up to 100 contacts. A round with more changes follows the cursor; see
  [Pagination](/concepts/pagination/).
- `modified_by_user` tells people from integrations, not one integration from another. Which user
  or application made a change is not readable through the public API on contacts.
- Conflicts (both sides changed the same contact in one round) are yours to settle: compare
  `modified_by_user` with the time of your own change.
