Developers
Set your API contextPick a region and space ID once. Every sample fills in your base URL.
Where do I find these?

In Coevera: open your space's API application and choose Show Access. It shows the space ID and the base URL, whose host starts with your region.

From the API: if you know the space ID, the discovery endpoint GET /api/discovery/spaces/{space_id} returns its region and base URLs, without credentials.

Only the region and space ID are kept, in this browser. Never paste a key or password here.

More in the quickstart

RecipesTwo-way contact sync

Two-way contact sync

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.

  • 20 min
  • REST and GraphQL
  • cURL · TypeScript · Python
  • Verified in the docs sandbox on 2026-10-15

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.
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 shows where the credentials come from, and Permissions and scopes where to set the permissions.

The operations this recipe calls, in the API reference: REST GET /entities/Clients, POST /entities/Contacts, PATCH /entities/Contacts/{id}, GET /entities/Contacts; GraphQL ClientRepository.getByCriteria, createContact, updateContact, ContactRepository.getByCriteria.

Set up a helper

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

Shell
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
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"
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;
}
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)
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"
}
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;
}
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.

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")
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);
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)
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")
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);
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)
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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"
    }
  ]
}
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "client": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0d000000-0000-4000-a000-000000000001",
                "name": "Dana Whitfield"
              }
            }
          ]
        }
      }
    }
  }
}

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.

# 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")
// 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);
# 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)
# 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")
// 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);
# 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)
201 CreatedRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
  }
}
Show all 65 linesShow less
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "createContact": {
      "contact": {
        "id": "0c000000-0000-4000-a000-000000000001"
      }
    }
  }
}

3. Push a change

The other system changed Priya's phone number.

# 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."
// 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.");
# 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.")
# 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
// 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.");
# 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.")
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
  }
}
Show all 65 linesShow less
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "updateContact": {
      "contact": {
        "id": "0c000000-0000-4000-a000-000000000001",
        "phone1": "+1 206 555 0141"
      }
    }
  }
}

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.
# 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"
// 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}`);
}
# 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}")
# 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"
// 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}`);
}
# 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}")
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
    }
  ]
}
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
              }
            }
          ]
        }
      }
    }
  }
}
Show all 36 linesShow less

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.
  • 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.