RecipesMerge duplicates
Merge duplicates
Merge a duplicate account into the record you keep, choose the field values that win, and see what happens to the secondary record and its relations, in REST or GraphQL.
Answer
POST /entities/Accounts/merge with master (the record to keep, plus any values that must win) and secondary_ids. The master keeps its non-empty values and takes the secondary's for its empty fields. The secondary is soft-deleted, unchanged. Its relations move to the master but keep their modified, so a sync by modified misses the move.
Wide World Importers is in the space twice: once as Wide World Importers, once as Wide World Importers Inc. with a buyer, Jonas Berg, attached. Merge the second into the first, keep a new order e-mail, and check what the merge did to both records and to Jonas's account relation. The program creates the two records itself; in your space you find duplicates with a lookup by name or e-mail.
Before you start
| Permissions | Full Access to Accounts and 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. |
| Context | Region and space ID come from the context bar. Credentials come from $COEVERA_ and $COEVERA_. |
Contacts have the same call, POST /entities/Contacts/merge (GraphQL mergeContact); this
recipe and its verification cover accounts.
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/Accounts,
POST /entities/Contacts,
POST /entities/Accounts/merge,
GET /entities/Accounts,
GET /entities/ContactAccountRelations; GraphQL
ClientRepository.getByCriteria,
createAccount,
createContact,
mergeAccount,
AccountRepository.getById,
AccountRepository.getByCriteria,
ContactAccountRelationRepository.getByCriteria.
Set up a helper
Every step below uses this setup. 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}"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");
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" },
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"])
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 bodyCOEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public"
# 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" \
-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");
// 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" },
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"])
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"]1. Find the owner
Every account and 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. Create the record to keep
The master: it has a sales e-mail and a phone number, but no city or comments.
# Two records for one company, each with values the other lacks. This one is kept.
master=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Accounts" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"name": "Wide World Importers", "owner_id": "$OWNER_ID",
"email1": "wideworld.sales@example.com", "phone1": "+1 206 555 0120"
}
JSON
)
echo "$master"
MASTER_ID=$(jq -er '.data.id' <<<"$master")// Two records for one company, each with values the other lacks. This one is kept.
const keep = await coevera("/entities/Accounts", {
method: "POST",
body: { name: "Wide World Importers", owner_id: ownerId, email1: "wideworld.sales@example.com", phone1: "+1 206 555 0120" },
});
const masterId: string = keep.data.id;
console.log("Master:", masterId);# Two records for one company, each with values the other lacks. This one is kept.
keep = coevera(
"POST",
"/entities/Accounts",
json={
"name": "Wide World Importers",
"owner_id": owner_id,
"email1": "wideworld.sales@example.com",
"phone1": "+1 206 555 0120",
},
)
master_id = keep["data"]["id"]
print("Master:", master_id)# Two records for one company, each with values the other lacks. This one is kept.
master=$(jq -n --arg owner "$OWNER_ID" '{
query: "mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { account { id name } } }",
variables: {
input: { name: "Wide World Importers", ownerId: $owner,
email1: "wideworld.sales@example.com", phone1: "+1 206 555 0120" }
}
}' | gql)
echo "$master"
MASTER_ID=$(jq -er '.data.createAccount.account.id' <<<"$master")// Two records for one company, each with values the other lacks. This one is kept.
const CREATE_ACCOUNT = `mutation CreateAccount($input: CreateAccountInput!) {
createAccount(input: $input) { account { id name } }
}`;
const keep = await gql(CREATE_ACCOUNT, {
input: { name: "Wide World Importers", ownerId, email1: "wideworld.sales@example.com", phone1: "+1 206 555 0120" },
});
const masterId: string = keep.createAccount.account.id;
console.log("Master:", masterId);# Two records for one company, each with values the other lacks. This one is kept.
CREATE_ACCOUNT = """
mutation CreateAccount($input: CreateAccountInput!) {
createAccount(input: $input) { account { id name } }
}
"""
keep = gql(
CREATE_ACCOUNT,
{
"input": {
"name": "Wide World Importers",
"ownerId": owner_id,
"email1": "wideworld.sales@example.com",
"phone1": "+1 206 555 0120",
}
},
)
master_id = keep["createAccount"]["account"]["id"]
print("Master:", master_id){
"success": true,
"data": {
"is_delete_protected": false,
"id": "0a000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.000000+00:00",
"created": "2026-10-15 09:00:00.000033+00:00",
"account_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/AccountTypes/03000000-0000-4000-a000-000000000001",
"currency_exchange_rates_list": null,
"customer_type": null,
"industry": null,
"owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001",
"parent_account": null,
"parent_account_relation_type": null,
"picture": null,
"unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001",
"account_class": 1,
"account_type_id": "03000000-0000-4000-a000-000000000001",
"address": "",
"city": "",
"comments": "",
"country": "",
"currency_exchange_rates_list_id": null,
"customer_type_id": null,
"email1": "wideworld.sales@example.com",
"email2": "",
"email3": "",
"email4": "",
"email5": "",
"health_category": null,
"health_status": null,
"home_page": "",
"industry_id": null,
"is_archived": false,
"last_ai_account_hierarchy_applied": null,
"last_ai_org_chart_applied": null,
"name": "Wide World Importers",
"owner_id": "0d000000-0000-4000-a000-000000000001",
"parent_account_id": null,
"parent_account_relation_type_id": null,
"phone1": "+1 206 555 0120",
"phone2": "",
"phone3": "",
"phone4": "",
"phone5": "",
"picture_id": null,
"quick_parent_account_name": "",
"share_mode": 0,
"state_province": "",
"unit_id": "05000000-0000-4000-a000-000000000001",
"zip_code": "",
"revision": 1433,
"formatted_name": "Wide World Importers",
"social_media": null,
"sharing_units": [],
"sharing_clients": [],
"documents": [],
"is_favorite": false,
"is_unsubscribed": false,
"tags": [],
"static_profiles": [],
"health": null,
"modified_by_user": null
}
}Show all 66 linesShow less
{
"data": {
"createAccount": {
"account": {
"id": "0a000000-0000-4000-a000-000000000001",
"name": "Wide World Importers"
}
}
}
}3. Create the duplicate
The secondary: another e-mail and phone number, and a city and comments the master lacks.
duplicate=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Accounts" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"name": "Wide World Importers Inc.", "owner_id": "$OWNER_ID",
"email1": "buying.wideworld@example.com", "phone1": "+1 206 555 0129",
"city": "Salem", "comments": "Imported from the trade-show list."
}
JSON
)
echo "$duplicate"
SECONDARY_ID=$(jq -er '.data.id' <<<"$duplicate")const duplicate = await coevera("/entities/Accounts", {
method: "POST",
body: {
name: "Wide World Importers Inc.",
owner_id: ownerId,
email1: "buying.wideworld@example.com",
phone1: "+1 206 555 0129",
city: "Salem",
comments: "Imported from the trade-show list.",
},
});
const secondaryId: string = duplicate.data.id;
console.log("Duplicate:", secondaryId);duplicate = coevera(
"POST",
"/entities/Accounts",
json={
"name": "Wide World Importers Inc.",
"owner_id": owner_id,
"email1": "buying.wideworld@example.com",
"phone1": "+1 206 555 0129",
"city": "Salem",
"comments": "Imported from the trade-show list.",
},
)
secondary_id = duplicate["data"]["id"]
print("Duplicate:", secondary_id)duplicate=$(jq -n --arg owner "$OWNER_ID" '{
query: "mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { account { id name } } }",
variables: {
input: { name: "Wide World Importers Inc.", ownerId: $owner,
email1: "buying.wideworld@example.com", phone1: "+1 206 555 0129",
city: "Salem", comments: "Imported from the trade-show list." }
}
}' | gql)
echo "$duplicate"
SECONDARY_ID=$(jq -er '.data.createAccount.account.id' <<<"$duplicate")const duplicate = await gql(CREATE_ACCOUNT, {
input: {
name: "Wide World Importers Inc.",
ownerId,
email1: "buying.wideworld@example.com",
phone1: "+1 206 555 0129",
city: "Salem",
comments: "Imported from the trade-show list.",
},
});
const secondaryId: string = duplicate.createAccount.account.id;
console.log("Duplicate:", secondaryId);duplicate = gql(
CREATE_ACCOUNT,
{
"input": {
"name": "Wide World Importers Inc.",
"ownerId": owner_id,
"email1": "buying.wideworld@example.com",
"phone1": "+1 206 555 0129",
"city": "Salem",
"comments": "Imported from the trade-show list.",
}
},
)
secondary_id = duplicate["createAccount"]["account"]["id"]
print("Duplicate:", secondary_id){
"success": true,
"data": {
"is_delete_protected": false,
"id": "0a000000-0000-4000-a000-000000000002",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.336684+00:00",
"created": "2026-10-15 09:00:00.336718+00:00",
"account_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/AccountTypes/03000000-0000-4000-a000-000000000001",
"currency_exchange_rates_list": null,
"customer_type": null,
"industry": null,
"owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001",
"parent_account": null,
"parent_account_relation_type": null,
"picture": null,
"unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001",
"account_class": 1,
"account_type_id": "03000000-0000-4000-a000-000000000001",
"address": "",
"city": "Salem",
"comments": "Imported from the trade-show list.",
"country": "",
"currency_exchange_rates_list_id": null,
"customer_type_id": null,
"email1": "buying.wideworld@example.com",
"email2": "",
"email3": "",
"email4": "",
"email5": "",
"health_category": null,
"health_status": null,
"home_page": "",
"industry_id": null,
"is_archived": false,
"last_ai_account_hierarchy_applied": null,
"last_ai_org_chart_applied": null,
"name": "Wide World Importers Inc.",
"owner_id": "0d000000-0000-4000-a000-000000000001",
"parent_account_id": null,
"parent_account_relation_type_id": null,
"phone1": "+1 206 555 0129",
"phone2": "",
"phone3": "",
"phone4": "",
"phone5": "",
"picture_id": null,
"quick_parent_account_name": "",
"share_mode": 0,
"state_province": "",
"unit_id": "05000000-0000-4000-a000-000000000001",
"zip_code": "",
"revision": 1434,
"formatted_name": "Wide World Importers Inc.",
"social_media": null,
"sharing_units": [],
"sharing_clients": [],
"documents": [],
"is_favorite": false,
"is_unsubscribed": false,
"tags": [],
"static_profiles": [],
"health": null,
"modified_by_user": null
}
}Show all 66 linesShow less
{
"data": {
"createAccount": {
"account": {
"id": "0a000000-0000-4000-a000-000000000002",
"name": "Wide World Importers Inc."
}
}
}
}4. Add a contact to the duplicate
Jonas Berg works at the secondary record, as its primary account. The merge moves this relation.
contact=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Contacts" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"first_name": "Jonas", "last_name": "Berg",
"email1": "jonas.berg@example.com",
"owner_id": "$OWNER_ID",
"account_relations": [{ "account_id": "$SECONDARY_ID", "position": "Buyer", "is_primary": true }]
}
JSON
)
echo "$contact"
CONTACT_ID=$(jq -er '.data.id' <<<"$contact")const contact = await coevera("/entities/Contacts", {
method: "POST",
body: {
first_name: "Jonas",
last_name: "Berg",
email1: "jonas.berg@example.com",
owner_id: ownerId,
account_relations: [{ account_id: secondaryId, position: "Buyer", is_primary: true }],
},
});
const contactId: string = contact.data.id;
console.log("Contact on the secondary:", contactId);contact = coevera(
"POST",
"/entities/Contacts",
json={
"first_name": "Jonas",
"last_name": "Berg",
"email1": "jonas.berg@example.com",
"owner_id": owner_id,
"account_relations": [{"account_id": secondary_id, "position": "Buyer", "is_primary": True}],
},
)
contact_id = contact["data"]["id"]
print("Contact on the secondary:", contact_id)contact=$(jq -n --arg owner "$OWNER_ID" --arg account "$SECONDARY_ID" '{
query: "mutation AddContact($input: CreateContactInput!) { createContact(input: $input) { contact { id } } }",
variables: {
input: {
firstName: "Jonas", lastName: "Berg",
email1: "jonas.berg@example.com",
ownerId: $owner,
accountRelations: [{ accountId: $account, position: "Buyer", isPrimary: true }]
}
}
}' | gql)
echo "$contact"
CONTACT_ID=$(jq -er '.data.createContact.contact.id' <<<"$contact")const contact = await gql(
`mutation AddContact($input: CreateContactInput!) {
createContact(input: $input) { contact { id } }
}`,
{
input: {
firstName: "Jonas",
lastName: "Berg",
email1: "jonas.berg@example.com",
ownerId,
accountRelations: [{ accountId: secondaryId, position: "Buyer", isPrimary: true }],
},
},
);
const contactId: string = contact.createContact.contact.id;
console.log("Contact on the secondary:", contactId);contact = gql(
"""
mutation AddContact($input: CreateContactInput!) {
createContact(input: $input) { contact { id } }
}
""",
{
"input": {
"firstName": "Jonas",
"lastName": "Berg",
"email1": "jonas.berg@example.com",
"ownerId": owner_id,
"accountRelations": [{"accountId": secondary_id, "position": "Buyer", "isPrimary": True}],
}
},
)
contact_id = contact["createContact"]["contact"]["id"]
print("Contact on the secondary:", contact_id){
"success": true,
"data": {
"is_delete_protected": false,
"id": "0c000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.757279+00:00",
"created": "2026-10-15 09:00:00.724431+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": "jonas.berg@example.com",
"email2": "",
"email3": "",
"email4": "",
"email5": "",
"first_name": "Jonas",
"gender": 0,
"is_archived": false,
"last_name": "Berg",
"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": 1435,
"formatted_name": "Jonas Berg",
"social_media": null,
"primary_account": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Accounts/0a000000-0000-4000-a000-000000000002",
"account_relations": [
"https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ContactAccountRelations/0e000000-0000-4000-a000-000000000001"
],
"sharing_units": [],
"sharing_clients": [],
"documents": [],
"exchange_entity_id": null,
"gapi_entity_id": null,
"o365_entity_id": null,
"is_favorite": false,
"primary_account_account_roles": [],
"primary_account_position": "Buyer",
"account_position": "Buyer",
"is_unsubscribed": false,
"tags": [],
"static_profiles": [],
"primary_account_relationship": 0,
"modified_by_user": null
}
}Show all 67 linesShow less
{
"data": {
"createContact": {
"contact": {
"id": "0c000000-0000-4000-a000-000000000001"
}
}
}
}5. Merge
Send master and secondary_ids. master takes the record's id and any field values that must
win over both records, here a new email1. REST takes the secondary records as a list in
secondary_ids; GraphQL mergeAccount takes one, in slaveId. The call answers 200 with the
master record.
master takes the fields an update takes. A read-only field, or a name the entity does not
have, fails the whole call with HTTP 400, code 117 ERROR_PARAMETER_INVALID, and nothing is
merged.
# master names the record to keep and carries the values that must win; the secondary
# records in secondary_ids are merged into it.
curl -sS --fail-with-body "$COEVERA_BASE/entities/Accounts/merge" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"master": { "id": "$MASTER_ID", "email1": "orders.wideworld@example.com" },
"secondary_ids": ["$SECONDARY_ID"]
}
JSON
echo// master names the record to keep and carries the values that must win; the secondary
// records in secondary_ids are merged into it.
const merged = await coevera("/entities/Accounts/merge", {
method: "POST",
body: { master: { id: masterId, email1: "orders.wideworld@example.com" }, secondary_ids: [secondaryId] },
});
console.log("Merged into:", merged.data.id);# master names the record to keep and carries the values that must win; the secondary
# records in secondary_ids are merged into it.
merged = coevera(
"POST",
"/entities/Accounts/merge",
json={"master": {"id": master_id, "email1": "orders.wideworld@example.com"}, "secondary_ids": [secondary_id]},
)
print("Merged into:", merged["data"]["id"])# master names the record to keep and carries the values that must win; slaveId is the one
# secondary record merged into it.
jq -n --arg master "$MASTER_ID" --arg secondary "$SECONDARY_ID" '{
query: "mutation Merge($input: MergeAccountInput!) { mergeAccount(input: $input) { result { id name } } }",
variables: { input: { master: { id: $master, email1: "orders.wideworld@example.com" }, slaveId: $secondary } }
}' | gql// master names the record to keep and carries the values that must win; slaveId is the one
// secondary record merged into it.
const merged = await gql(
`mutation Merge($input: MergeAccountInput!) {
mergeAccount(input: $input) { result { id name } }
}`,
{ input: { master: { id: masterId, email1: "orders.wideworld@example.com" }, slaveId: secondaryId } },
);
console.log("Merged into:", merged.mergeAccount.result.id);# master names the record to keep and carries the values that must win; slaveId is the one
# secondary record merged into it.
merged = gql(
"""
mutation Merge($input: MergeAccountInput!) {
mergeAccount(input: $input) { result { id name } }
}
""",
{"input": {"master": {"id": master_id, "email1": "orders.wideworld@example.com"}, "slaveId": secondary_id}},
)
print("Merged into:", merged["mergeAccount"]["result"]["id"]){
"success": true,
"data": {
"is_delete_protected": false,
"id": "0a000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:07.153829+00:00",
"created": "2026-10-15 09:00:00.000033+00:00",
"account_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/AccountTypes/03000000-0000-4000-a000-000000000001",
"currency_exchange_rates_list": null,
"customer_type": null,
"industry": null,
"owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001",
"parent_account": null,
"parent_account_relation_type": null,
"picture": null,
"unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001",
"account_class": 1,
"account_type_id": "03000000-0000-4000-a000-000000000001",
"address": "",
"city": "Salem",
"comments": "Imported from the trade-show list.",
"country": "",
"currency_exchange_rates_list_id": null,
"customer_type_id": null,
"email1": "orders.wideworld@example.com",
"email2": "",
"email3": "",
"email4": "",
"email5": "",
"health_category": null,
"health_status": null,
"home_page": "",
"industry_id": null,
"is_archived": false,
"last_ai_account_hierarchy_applied": null,
"last_ai_org_chart_applied": null,
"name": "Wide World Importers",
"owner_id": "0d000000-0000-4000-a000-000000000001",
"parent_account_id": null,
"parent_account_relation_type_id": null,
"phone1": "+1 206 555 0120",
"phone2": "",
"phone3": "",
"phone4": "",
"phone5": "",
"picture_id": null,
"quick_parent_account_name": "",
"share_mode": 0,
"state_province": "",
"unit_id": "05000000-0000-4000-a000-000000000001",
"zip_code": "",
"revision": 1436,
"formatted_name": "Wide World Importers",
"social_media": null,
"sharing_units": [],
"sharing_clients": [],
"documents": [],
"is_favorite": false,
"is_unsubscribed": false,
"tags": [],
"static_profiles": [],
"health": null,
"modified_by_user": null
}
}Show all 66 linesShow less
{
"data": {
"mergeAccount": {
"result": {
"id": "0a000000-0000-4000-a000-000000000001",
"name": "Wide World Importers"
}
}
}
}What the merge does to the values:
- The master keeps its own non-empty values:
nameandphone1. - An empty master field takes the secondary's value:
cityandcomments. - A value sent in
masterwins over both records:email1.
| Field | Master before | Secondary | Master after |
|---|---|---|---|
name |
Wide World Importers | Wide World Importers Inc. | Wide World Importers |
phone1 |
+1 206 555 0120 | +1 206 555 0129 | +1 206 555 0120 |
city |
(empty) | Salem | Salem |
comments |
(empty) | Imported from the trade-show list. | Imported from the trade-show list. |
email1 |
wideworld. |
buying. |
orders. |
6. Read the master back
curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \
-d "filter[id]=$MASTER_ID" \
-d load-only=id,name,email1,phone1,city,comments,modified \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET"
echoconst master = await coevera(
`/entities/Accounts?filter[id]=${masterId}&load-only=id,name,email1,phone1,city,comments,modified`,
);
console.log("Master:", JSON.stringify(master.data[0], null, 2));master = coevera(
"GET",
"/entities/Accounts",
params={"filter[id]": master_id, "load-only": "id,name,email1,phone1,city,comments,modified"},
)
print("Master:", json.dumps(master["data"][0], indent=2))jq -n --arg id "$MASTER_ID" '{
query: "query ReadMaster($id: ID!) { entities { account { getById(entityId: $id) { id name email1 phone1 city comments modified } } } }",
variables: { id: $id }
}' | gqlconst master = await gql(
`query ReadMaster($id: ID!) {
entities { account { getById(entityId: $id) { id name email1 phone1 city comments modified } } }
}`,
{ id: masterId },
);
console.log("Master:", JSON.stringify(master.entities.account.getById, null, 2));master = gql(
"""
query ReadMaster($id: ID!) {
entities { account { getById(entityId: $id) { id name email1 phone1 city comments modified } } }
}
""",
{"id": master_id},
)
print("Master:", json.dumps(master["entities"]["account"]["getById"], indent=2)){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0a000000-0000-4000-a000-000000000001",
"modified": "2026-10-15 09:00:07.153829+00:00",
"city": "Salem",
"comments": "Imported from the trade-show list.",
"email1": "orders.wideworld@example.com",
"name": "Wide World Importers",
"phone1": "+1 206 555 0120"
}
]
}{
"data": {
"entities": {
"account": {
"getById": {
"id": "0a000000-0000-4000-a000-000000000001",
"name": "Wide World Importers",
"email1": "orders.wideworld@example.com",
"phone1": "+1 206 555 0120",
"city": "Salem",
"comments": "Imported from the trade-show list.",
"modified": "2026-10-15T09:00:00.909865+00:00"
}
}
}
}
}7. Read the secondary back
The secondary is soft-deleted (is_deleted: true; it is not archived), and its own values are
untouched. A list call shows it only with include-deleted=true (GraphQL includeDeleted: true);
GET /entities/Accounts/{id} still answers 200 for it.
# The secondary is soft-deleted: list calls show it only with include-deleted=true.
curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \
-d "filter[id]=$SECONDARY_ID" -d include-deleted=true \
-d load-only=id,name,email1,phone1,city,is_deleted \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET"
echo// The secondary is soft-deleted: list calls show it only with include-deleted=true.
const secondary = await coevera(
`/entities/Accounts?filter[id]=${secondaryId}&include-deleted=true&load-only=id,name,email1,phone1,city,is_deleted`,
);
console.log("Secondary:", JSON.stringify(secondary.data[0], null, 2));# The secondary is soft-deleted: list calls show it only with include-deleted=true.
secondary = coevera(
"GET",
"/entities/Accounts",
params={
"filter[id]": secondary_id,
"include-deleted": "true",
"load-only": "id,name,email1,phone1,city,is_deleted",
},
)
print("Secondary:", json.dumps(secondary["data"][0], indent=2))# The secondary is soft-deleted: it shows only with includeDeleted: true.
jq -n --arg id "$SECONDARY_ID" '{
query: "query ReadSecondary($id: String!) { entities { account { getByCriteria(includeDeleted: true, filter: { id: { eq: [$id] } }) { edges { node { id name email1 phone1 city isDeleted } } } } } }",
variables: { id: $id }
}' | gql// The secondary is soft-deleted: it shows only with includeDeleted: true.
const secondary = await gql(
`query ReadSecondary($id: String!) {
entities {
account {
getByCriteria(includeDeleted: true, filter: { id: { eq: [$id] } }) {
edges { node { id name email1 phone1 city isDeleted } }
}
}
}
}`,
{ id: secondaryId },
);
console.log("Secondary:", JSON.stringify(secondary.entities.account.getByCriteria.edges, null, 2));# The secondary is soft-deleted: it shows only with includeDeleted: true.
secondary = gql(
"""
query ReadSecondary($id: String!) {
entities {
account {
getByCriteria(includeDeleted: true, filter: { id: { eq: [$id] } }) {
edges { node { id name email1 phone1 city isDeleted } }
}
}
}
}
""",
{"id": secondary_id},
)
print("Secondary:", json.dumps(secondary["entities"]["account"]["getByCriteria"]["edges"], indent=2)){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==",
"end_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0a000000-0000-4000-a000-000000000002",
"is_deleted": true,
"city": "Salem",
"email1": "buying.wideworld@example.com",
"name": "Wide World Importers Inc.",
"phone1": "+1 206 555 0129"
}
]
}{
"data": {
"entities": {
"account": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "0a000000-0000-4000-a000-000000000002",
"name": "Wide World Importers Inc.",
"email1": "buying.wideworld@example.com",
"phone1": "+1 206 555 0129",
"city": "Salem",
"isDeleted": true
}
}
]
}
}
}
}
}8. Read the contact's relation
The secondary's relations move to the master in place: Jonas's ContactAccountRelations row keeps
its id, is_primary and position, and its account_id now names the master.
# The contact's relation now points at the master, with its old modified.
curl -sS --fail-with-body -G "$COEVERA_BASE/entities/ContactAccountRelations" \
-d "filter[contact_id]=$CONTACT_ID" \
-d load-only=id,account_id,position,is_primary,modified \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET"// The contact's relation now points at the master, with its old modified.
const relations = await coevera(
`/entities/ContactAccountRelations?filter[contact_id]=${contactId}&load-only=id,account_id,position,is_primary,modified`,
);
console.log("Relation:", JSON.stringify(relations.data, null, 2));# The contact's relation now points at the master, with its old modified.
relations = coevera(
"GET",
"/entities/ContactAccountRelations",
params={"filter[contact_id]": contact_id, "load-only": "id,account_id,position,is_primary,modified"},
)
print("Relation:", json.dumps(relations["data"], indent=2))# The contact's relation now points at the master, with its old modified.
jq -n --arg contact "$CONTACT_ID" '{
query: "query ReadRelation($contact: String!) { entities { contactAccountRelation { getByCriteria(filter: { contactId: { eq: [$contact] } }) { edges { node { id accountId position isPrimary modified } } } } } }",
variables: { contact: $contact }
}' | gql// The contact's relation now points at the master, with its old modified.
const relations = await gql(
`query ReadRelation($contactId: String!) {
entities {
contactAccountRelation {
getByCriteria(filter: { contactId: { eq: [$contactId] } }) {
edges { node { id accountId position isPrimary modified } }
}
}
}
}`,
{ contactId },
);
console.log("Relation:", JSON.stringify(relations.entities.contactAccountRelation.getByCriteria.edges, null, 2));# The contact's relation now points at the master, with its old modified.
relations = gql(
"""
query ReadRelation($contactId: String!) {
entities {
contactAccountRelation {
getByCriteria(filter: { contactId: { eq: [$contactId] } }) {
edges { node { id accountId position isPrimary modified } }
}
}
}
}
""",
{"contactId": contact_id},
)
print("Relation:", json.dumps(relations["entities"]["contactAccountRelation"]["getByCriteria"]["edges"], indent=2)){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwZTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwZTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0e000000-0000-4000-a000-000000000001",
"modified": "2026-10-15 09:00:00.741041+00:00",
"account_id": "0a000000-0000-4000-a000-000000000001",
"is_primary": true,
"position": "Buyer"
}
]
}{
"data": {
"entities": {
"contactAccountRelation": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "0e000000-0000-4000-a000-000000000001",
"accountId": "0a000000-0000-4000-a000-000000000001",
"position": "Buyer",
"isPrimary": true,
"modified": "2026-10-15T09:00:00.000000+00:00"
}
}
]
}
}
}
}
}Warning
A re-pointed relation keeps its modified, and so does the contact. An integration that syncs
changes by modified never sees the contact move to the master. After a merge, read the master's
relations again, or re-read the records you track.
What to check
- Step 5 answers 200 with the master's
id. - Step 6 shows the kept, filled and sent values from the table above.
- Step 7 returns the secondary with
is_deleted: trueand its ownemail1andname. - Step 8 returns one relation whose
account_idis the master and whosemodifiedis older than the master's. - Run the program twice and you get a second pair, merged the same way. To make a merge job
safe to repeat, check that the secondary is not already deleted (
is_deleted) before you merge.
Clean up
The program leaves the master account, the soft-deleted secondary and the contact in your space.
Delete the contact, then the master, with batch-delete, and then the contact's
ContactAccountRelations row: deleting a contact or an account leaves it active.
Limits
- The program merges one pair. REST takes a list of ids in
secondary_ids; GraphQL takes one per call.