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.
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. 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_ and $COEVERA_. |
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 changemodifiedbut leavemodified_by_useralone: it staysnullafter the program's writes. A pulled contact whosemodified_by_useris 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:
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>" # never commit itCOEVERA_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){
"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"
}
]
}{
"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){
"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
{
"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."){
"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
{
"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_ |
Delete it. |
modified_ at or after the round's start |
A person changed it in Coevera: apply the change. |
modified_ 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}"){
"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
}
]
}{
"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: falseandmodified_by_user: null(GraphQLmodifiedByUser), so the program skips her: no echo. - The rows come oldest
modifiedfirst. - 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_usertells 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_userwith the time of your own change.