# Coevera Developers: full text > Every guide, concept and recipe page of https://developers.coevera.com/ as Markdown, in reading order. Each > page starts with its front matter (title, canonical URL, verification label). The index is > https://developers.coevera.com/llms.txt; the API reference is in https://developers.coevera.com/llms-reference.txt. --- title: "Quickstart: your first API call" description: Create an API application in Coevera, copy its credentials and list your accounts, in REST or GraphQL, with cURL, TypeScript or Python. canonical: https://developers.coevera.com/get-started/quickstart/ section: get-started verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Quickstart: your first API call **Answer:** An admin creates an API application in the CRM and copies its user name, password, space ID and service URL. Your code sends the user name and password with HTTP Basic auth to https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}. A GET on /entities/Accounts with first=3 returns the first three accounts as JSON. From an empty terminal to a first successful call: create an API application, copy its credentials, and list the accounts in your space. It takes about five minutes, in REST or GraphQL. ## Before you begin - A Coevera space in which you hold the **Admin API Access** right. Only such a user can open **Applications**; for everyone else the **Obtain API Key** button is disabled. - A space to test in. Coevera has no self-serve developer sandbox, so use a space without production data: a [14-day free trial](https://www.coevera.com/crm/free-trial/) gives you a space of your own. Every Coevera plan includes the API; the monthly call limits are in [Plans and limits](/get-started/plans-and-limits/). - `curl`, Node.js 18 or later, or Python 3.9 or later with `requests`. ## Get API credentials Every integration authenticates as an **API application**. Each one has its own user name, password and permissions, so you can give an integration only the access it needs and revoke it without breaking the others. 1. **Open the applications list.** In the CRM, open **Administration** from the space menu (the app switcher calls it **Admin**) and go to **Units, Users & Roles › Applications**. The button **Administration › General › Obtain API Key** opens the same place. 2. **Create the application.** Click **Create New** and name it after your integration, for example _Northwind ERP sync_. Then click **Edit Permissions**: **Read Only Access** on **Accounts**, in the **Records** tab, is enough for this quickstart. 3. **Copy the access values.** Click **Show Access**. The **API Access** dialog shows four values, each with a copy button: | Value | Where it goes | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **User Name** | `COEVERA_API_KEY`. It starts with your space ID. | | **Password** | `COEVERA_API_SECRET`. It is shown in full only right after the application is created or its credentials are regenerated, so copy it then. | | **Space ID** | The context bar at the top of this page, and every URL. | | **Service URL** | The host to call, in your space's region. Take it from here or from the [discovery endpoint](/get-started/regions-and-base-urls/#find-a-spaces-region); do not build it by hand. | **Generate New**, in the same dialog, replaces both the user name and the password, and the previous pair stops working at once. Only API applications of type Basic have **Show Access**. An application that an AI connector created is of type OAuth and offers **View Permissions** only. ## Set your context Pick your **region** and enter your **space ID** in the context bar at the top of the page: every sample on this site then fills in your base URL. Keep the credentials out of your code and out of every URL; export them as environment variables: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` The REST API for a space answers at: ```text https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id} ``` The region is one of `us-east`, `eu-central`, `ca-central` or `ap-southeast`; see [Regions and base URLs](/get-started/regions-and-base-urls/). ## Make your first request List the first three accounts in the space, with only the fields you need: `first=3` asks for three records and `load-only` names the fields each one carries. Pick REST or GraphQL, and your language: **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ -d first=3 \ -d load-only=id,name,email1,modified \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); const params = new URLSearchParams({ first: "3", "load-only": "id,name,email1,modified" }); const res = await fetch(`${base}/entities/Accounts?${params}`, { headers: { Authorization: `Basic ${auth}` }, }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${body.message}`); console.log(JSON.stringify(body, null, 2)); ``` **REST · Python** ```python import json import os import sys import requests BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" AUTH = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) response = requests.get( f"{BASE}/entities/Accounts", params={"first": 3, "load-only": "id,name,email1,modified"}, auth=AUTH, timeout=30, ) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {body.get('message')}") print(json.dumps(body, indent=2)) ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d '{"query": "{ entities { account { getByCriteria(first: 3) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name email1 modified } } } } } }"}' ``` **GraphQL · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" }, body: JSON.stringify({ query: `{ entities { account { getByCriteria(first: 3) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name email1 modified } } } } } }`, }), }); const body = await res.json(); // GraphQL reports errors in the body, often with HTTP 200: check both. if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`); console.log(JSON.stringify(body.data, null, 2)); ``` **GraphQL · Python** ```python import json import os import sys import requests ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) AUTH = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) QUERY = """ { entities { account { getByCriteria(first: 3) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name email1 modified } } } } } } """ response = requests.post(ENDPOINT, json={"query": QUERY}, auth=AUTH, timeout=30) body = response.json() # GraphQL reports errors in the body, often with HTTP 200: check both. if not response.ok or body.get("errors"): sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}") print(json.dumps(body["data"], indent=2)) ``` The response looks like this; the ids and names in your own space differ: **Recorded response · REST · 200 OK** ```json { "success": true, "total": 4, "page_info": { "start_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDMiXQ==", "has_previous_page": false, "has_next_page": true }, "data": [ { "id": "0a000000-0000-4000-a000-000000000001", "modified": "2026-10-14 09:00:00.000000+00:00", "email1": "northwind@example.com", "name": "Northwind Analytics" }, { "id": "0a000000-0000-4000-a000-000000000002", "modified": "2026-10-14 09:00:00.740429+00:00", "email1": "contoso@example.com", "name": "Contoso Freight" }, { "id": "0a000000-0000-4000-a000-000000000003", "modified": "2026-10-14 09:00:01.285049+00:00", "email1": "wideworld@example.com", "name": "Wide World Importers" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "account": { "getByCriteria": { "totalCount": 4, "pageInfo": { "hasNextPage": true, "endCursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDMiXQ==" }, "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Northwind Analytics", "email1": "northwind@example.com", "modified": "2026-10-14T09:00:00.000000+00:00" } }, { "node": { "id": "0a000000-0000-4000-a000-000000000002", "name": "Contoso Freight", "email1": "contoso@example.com", "modified": "2026-10-14T09:00:00.740429+00:00" } }, { "node": { "id": "0a000000-0000-4000-a000-000000000003", "name": "Wide World Importers", "email1": "wideworld@example.com", "modified": "2026-10-14T09:00:01.285049+00:00" } } ] } } } } } ``` _Recorded on 2026-10-14 against API 6.5.2 in the docs sandbox, de-identified._ A REST list comes back in an envelope: `success`, `total` (how many accounts match, not how many came back), `page_info` with the cursors for the next page, and `data` with the records. A single record comes back as `success` and `data`. In GraphQL the list comes back as `totalCount`, `pageInfo` and `edges`. Continue with [pagination](/concepts/pagination/) to read more than one page, and [querying](/concepts/querying/) to filter, sort and trim a list. Every entity and its operations are in the [REST reference](/reference/rest/) and the [GraphQL reference](/reference/graphql/). > **Limit:** A REST list request returns at most 100 records per page, and 30 when you do not set `first`. > A larger `first` answers `400` with `ERROR_PARAMETER_INVALID`. In GraphQL, set `first` on every > list query. ## If you get 401 Every authentication failure answers HTTP `401` with `ERROR_UNAUTHORIZED`, and the reason is in `message`; the other errors are in [errors](/concepts/errors/). This is what a request without credentials gets; in GraphQL the status is the same and the body is shorter: **Recorded response · REST · 401 Unauthorized** ```json { "code": 401, "name": "ERROR_UNAUTHORIZED", "message": "Access denied (Unauthorized call.).", "http_status": 401, "success": false } ``` **Recorded response · GraphQL · 401 Unauthorized** ```json { "status": 401, "message": "Access denied (Unauthorized call.).", "errorcode": null, "traceback": null } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ The causes, each with its fix: - **No credentials.** The reason reads `Unauthorized call.` Check that both environment variables are set in the shell that runs the program. - **Wrong space.** The user name starts with the ID of the space it belongs to, and works only there. If the space in the URL is a different one, the call is refused like a wrong password: the reason reads `Invalid Api user credentials`, and it counts as a failed sign-in. Use the space ID from **Show Access**. - **Credentials regenerated.** Someone pressed **Generate New**, which replaces the user name and the password. Copy both new values. > **A wrong region is not a cause:** A call to another region's host is forwarded to the region that holds your space and answers > normally, with the same credentials, only more slowly. Use the region the > [discovery endpoint](/get-started/regions-and-base-urls/#find-a-spaces-region) returns. ## Next steps - [Create records](/recipes/create-account-with-contacts/): An account with its contacts, in one batch. - [Query data](/concepts/querying/): Filters, sort, expand and the query builder. - [React to changes](/webhooks/overview/): Webhooks with signed deliveries. Before you build on it, read [authentication](/get-started/authentication/) for how the credentials are sent and rotated, [permissions and scopes](/get-started/permissions-and-scopes/) for what an application may do, and [REST or GraphQL?](/get-started/rest-or-graphql/) for which API to use. [Troubleshooting](/support/troubleshooting/) has the common failures in one place. --- title: Authentication description: Integrations authenticate as an API application, with its user name and password over HTTP Basic. OAuth 2.0 is used only by Coevera's AI connectors. canonical: https://developers.coevera.com/get-started/authentication/ section: get-started verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Authentication **Answer:** Create an API application and send its user name and password with every request as Authorization: Basic, the base64 of "user name:password". Each application has its own permissions and works in one space only. A failed sign-in answers 401 ERROR_UNAUTHORIZED. OAuth 2.0 is used by Coevera's AI connectors; general OAuth apps are not yet available. Every call to the REST or GraphQL API carries the credentials of an **API application**. OAuth 2.0 exists too, but only behind the AI connectors that Coevera registers. ## API keys (Basic auth) API applications with HTTP Basic auth are the documented way to integrate. An admin creates the application under **Administration › Units, Users & Roles › Applications** (the [quickstart](/get-started/quickstart/#get-api-credentials) walks through it) and sets what it may do with **Edit Permissions**, on three tabs: **Records**, **Administration** and **Misc**. Each entry is **Full Access**, **Read Only Access** or **No Access**; see [Permissions and scopes](/get-started/permissions-and-scopes/). | What | How it works | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Credential belongs to | The application, not the space or a person. A space can have many applications, each with its own credentials and permissions. | | User name | The space ID, an underscore and 40 letters and digits, such as `nv2_northwind_docs_` followed by 40 characters. It works only in that space. | | Password | Shown in full only when the application is created or its credentials are regenerated. It stays valid until someone presses **Generate New**, which replaces the user name too. | | Failed sign-in | HTTP `401`, [`ERROR_UNAUTHORIZED`](/reference/errors/#code-401), with the reason in `message`. | | Missing permission | HTTP `400` with code [`50`](/reference/errors/#code-50), `ERROR_NOT_ENOUGH_PERMISSIONS`. The API never answers `403`; see [errors](/concepts/errors/). | | Too many failed sign-ins | HTTP `429`, `ERROR_TOO_MANY_REQUESTS`. Stop at the first `401` instead of retrying; see [rate limits and retries](/concepts/rate-limits-and-retries/). | ## Send the credentials HTTP Basic auth sends `Authorization: Basic` and the base64 of the user name, a colon and the password, on every request: ```text GET /api/v100/rest/spaces/{space_id}/entities/Accounts?first=1 HTTP/1.1 Host: {region}.api.coevera.com Authorization: Basic Accept: application/json ``` `curl` builds the header from `-u` and `requests` from its `auth` argument; in Node.js you build it with `Buffer`. Never put the credentials in the URL or the query string. The program below sends the same request twice, with the application's credentials and without them: **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # -u sends the user name and password as the Authorization: Basic header. curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ -d first=1 \ -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" echo # The same request without -u: the API answers 401 and says why in the message. curl -sS -G "$COEVERA_BASE/entities/Accounts" \ -d first=1 \ -d load-only=id,name \ -w '\nHTTP %{http_code}\n' ``` **REST · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; // HTTP Basic: the base64 of "user name:password". const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); const url = `${base}/entities/Accounts?${new URLSearchParams({ first: "1", "load-only": "id,name" })}`; const res = await fetch(url, { headers: { Authorization: `Basic ${auth}` } }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${body.message}`); console.log(JSON.stringify(body, null, 2)); // The same request without the Authorization header: the API answers 401 and says why. const refused = await fetch(url); console.log(`HTTP ${refused.status}`, JSON.stringify(await refused.json(), null, 2)); ``` **REST · Python** ```python import json import os import sys import requests BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # requests sends a (user name, password) tuple as HTTP Basic auth. AUTH = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) PARAMS = {"first": 1, "load-only": "id,name"} response = requests.get(f"{BASE}/entities/Accounts", params=PARAMS, auth=AUTH, timeout=30) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {body.get('message')}") print(json.dumps(body, indent=2)) # The same request without auth: the API answers 401 and says why in the message. refused = requests.get(f"{BASE}/entities/Accounts", params=PARAMS, timeout=30) print(f"HTTP {refused.status_code}", json.dumps(refused.json(), indent=2)) ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" QUERY='{"query": "{ entities { account { getByCriteria(first: 1) { totalCount edges { node { id name } } } } } }"}' # -u sends the user name and password as the Authorization: Basic header. curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$QUERY" echo # The same request without -u: the API answers 401 and says why in the message. curl -sS "$COEVERA_GRAPHQL" \ -H "Content-Type: application/json" \ -d "$QUERY" \ -w '\nHTTP %{http_code}\n' ``` **GraphQL · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; // HTTP Basic: the base64 of "user name:password". const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); const query = JSON.stringify({ query: "{ entities { account { getByCriteria(first: 1) { totalCount edges { node { id name } } } } } }", }); const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" }, body: query, }); const body = await res.json(); // GraphQL reports errors in the body, often with HTTP 200: check both. if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`); console.log(JSON.stringify(body.data, null, 2)); // The same request without the Authorization header: the API answers 401 and says why. const refused = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: query }); console.log(`HTTP ${refused.status}`, JSON.stringify(await refused.json(), null, 2)); ``` **GraphQL · Python** ```python import json import os import sys import requests ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) # requests sends a (user name, password) tuple as HTTP Basic auth. AUTH = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) QUERY = "{ entities { account { getByCriteria(first: 1) { totalCount edges { node { id name } } } } } }" response = requests.post(ENDPOINT, json={"query": QUERY}, auth=AUTH, timeout=30) body = response.json() # GraphQL reports errors in the body, often with HTTP 200: check both. if not response.ok or body.get("errors"): sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}") print(json.dumps(body["data"], indent=2)) # The same request without auth: the API answers 401 and says why in the message. refused = requests.post(ENDPOINT, json={"query": QUERY}, timeout=30) print(f"HTTP {refused.status_code}", json.dumps(refused.json(), indent=2)) ``` The first request returns one account. The second answers `401`, with the reason in `message`. In GraphQL the status is the same and the body is shorter: **Recorded response · REST · 401 Unauthorized** ```json { "code": 401, "name": "ERROR_UNAUTHORIZED", "message": "Access denied (Unauthorized call.).", "http_status": 401, "success": false } ``` **Recorded response · GraphQL · 401 Unauthorized** ```json { "status": 401, "message": "Access denied (Unauthorized call.).", "errorcode": null, "traceback": null } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ The [quickstart](/get-started/quickstart/#if-you-get-401) lists the causes of a `401` and their fixes. ## Rotate the credentials Open the application, click **Show Access**, then **Generate New**. The CRM asks you to confirm, then shows a new user name and a new password. The old pair stops working at once, so plan for it: - **With a short window.** Generate the new pair, then update the integration straight away; calls in between answer `401`. - **Without a window.** Create a second application with the same permissions, switch the integration to its credentials, then delete the old application. Copy both values every time: an integration that updates only the password still gets `401`. > **The IP allow-list and API applications:** The [5.22.0 release notes](https://www.coevera.com/release-notes/outlook-add-in-improvements-advanced-sign-in-security-settings-expanded-automatizer-capabilities-and-other-various-improvements-v5-22-0/) > say that a sign-in IP allow-list (Advanced Sign-In Security) restricts API applications too, > while the integrations that Coevera provides and its AI tools are not restricted. ## Users, single sign-on and applications An API application is not a person. People sign in to Coevera with a password or with single sign-on, and two-factor authentication can apply to them; none of that is used for API calls. An integration always authenticates as an application, with its own user name and password, whatever way the space's users sign in. ## OAuth for AI connectors The Claude, ChatGPT and custom MCP connectors sign users in with OAuth 2.0. You meet this flow when you [connect an AI assistant](/ai/overview/); you do not build it yourself. | Item | Value | | --------------- | ------------------------------------------------------------------------------------------------------- | | Grant | Authorization code with PKCE (`S256`) | | Endpoints | `/oauth2/app/authorize`, `/oauth2/app/token`, `/oauth2/app/introspection`, `/oauth2/app/revocation` | | Access token | Bearer, valid for 1 hour | | Refresh token | Valid for 90 days; every refresh returns a new one and revokes the old pair | | Scopes | `.read` and `.full`, such as `accounts.full`; see [OAuth and scopes](/ai/oauth-and-scopes/) | | Who can consent | A user with the **Admin API Access** right in that space | > **General OAuth apps: not yet available:** Coevera registers OAuth clients itself; there is no self-service registration. If you are > building a product for many Coevera customers, [contact us](/support/support-and-status/). Until > then, use an API application. ## Which one applies to you | | API application (Basic) | OAuth (AI connectors) | | ---------- | ------------------------------------------- | ----------------------------------------------------------------- | | Use it for | Your own integration, script, sync or agent | Claude, ChatGPT, a custom MCP client | | Set up by | A space admin, in the CRM | The user, in the assistant, after Coevera activates the connector | | Acts with | The application's permissions | The scopes the user approved | ## Limits - A credential works in one space only. An integration that serves several spaces needs an application in each. - An application has one user name and password at a time. They do not expire; they change only with **Generate New**. - There is no self-service OAuth client registration. --- title: Regions and base URLs description: Every Coevera space lives in one of four regions. Its base URLs name the region and the space, and the discovery endpoint finds them from the space ID alone. canonical: https://developers.coevera.com/get-started/regions-and-base-urls/ section: get-started verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Regions and base URLs **Answer:** Every space lives in one of four regions: us-east, eu-central, ca-central or ap-southeast. Call https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}, with the host from Show Access › Service URL or from GET /api/discovery/spaces/{space_id}, which needs no credentials. A call to another region's host still works: it is forwarded, only slower. A space's data stays in the region the customer chose, and its API answers there. Your base URL names that region and the space. ## Regions | Region | Name | Data centre | REST base URL | | -------------- | ------------ | ------------------ | ---------------------------------------------------------------------- | | `us-east` | US East | Virginia, USA | `https://us-east.api.coevera.com/api/v100/rest/spaces/{space_id}` | | `eu-central` | EU Central | Frankfurt, Germany | `https://eu-central.api.coevera.com/api/v100/rest/spaces/{space_id}` | | `ca-central` | CA Central | Toronto, Canada | `https://ca-central.api.coevera.com/api/v100/rest/spaces/{space_id}` | | `ap-southeast` | AP Southeast | Sydney, Australia | `https://ap-southeast.api.coevera.com/api/v100/rest/spaces/{space_id}` | The data centres are the ones the [Coevera security page](https://www.coevera.com/security/) lists, which also says that customer data is not moved between regions. Take the host from **Service URL** in **Show Access**, or from the discovery endpoint below. Do not build it by hand from the space ID. Each region's host has the form `{region}.api.coevera.com`, the form the discovery endpoint returns. ## Base URLs | What | URL | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | REST | `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}` | | GraphQL | `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`, `POST` only: a `GET` answers `405` | | OpenAPI for your space | `…/rest/spaces/{space_id}/openapi.json`. It needs your credentials and is built for your space, with its custom fields and custom entities. | | Public GraphQL schema | `…/graphql/public/schema.json`, the introspection result as JSON. It needs no credentials. | | Version | `v100`, the only API version; see [Versioning and deprecation](/changelog/versioning-and-deprecation/). | The published contract, without your space's custom fields, is in the [REST](/reference/rest/) and [GraphQL](/reference/graphql/) reference and its [downloads](/reference/downloads/). ## Find a space's region The discovery endpoint answers `GET /api/discovery/spaces/{space_id}`. It needs no credentials, and each of the four regional hosts answers it for every space, so the request works whichever region the context bar shows. It returns the space's region and its REST and GraphQL URLs: **REST · cURL** ```bash DISCOVERY_URL="https://{region}.api.coevera.com/api/discovery/spaces/{space_id}" # Public: no credentials. Prints the region's host and the space's REST base URL. curl -sS --fail-with-body "$DISCOVERY_URL" | jq -r '.region_url, .rest.space.endpoint' ``` **REST · TypeScript** ```ts import process from "node:process"; const discoveryUrl = `https://{region}.api.coevera.com/api/discovery/spaces/{space_id}`; // Public: no credentials. const res = await fetch(discoveryUrl); const space = await res.json(); if (!res.ok) { console.error(`HTTP ${res.status} ${space.name}: ${space.message}`); process.exit(1); } console.log(space.region_url); console.log(space.rest.space.endpoint); ``` **REST · Python** ```python import os import sys import requests DISCOVERY_URL = "https://{region}.api.coevera.com/api/discovery/spaces/{space_id}" # Public: no credentials. response = requests.get(DISCOVERY_URL, timeout=30) space = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {space.get('name')}: {space.get('message')}") print(space["region_url"]) print(space["rest"]["space"]["endpoint"]) ``` **GraphQL · cURL** ```bash DISCOVERY_URL="https://{region}.api.coevera.com/api/discovery/spaces/{space_id}" # Public: no credentials. Prints the region's host and the space's GraphQL endpoint. curl -sS --fail-with-body "$DISCOVERY_URL" | jq -r '.region_url, .graphql.space.endpoint' ``` **GraphQL · TypeScript** ```ts import process from "node:process"; const discoveryUrl = `https://{region}.api.coevera.com/api/discovery/spaces/{space_id}`; // Public: no credentials. const res = await fetch(discoveryUrl); const space = await res.json(); if (!res.ok) { console.error(`HTTP ${res.status} ${space.name}: ${space.message}`); process.exit(1); } console.log(space.region_url); console.log(space.graphql.space.endpoint); ``` **GraphQL · Python** ```python import os import sys import requests DISCOVERY_URL = "https://{region}.api.coevera.com/api/discovery/spaces/{space_id}" # Public: no credentials. response = requests.get(DISCOVERY_URL, timeout=30) space = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {space.get('name')}: {space.get('message')}") print(space["region_url"]) print(space["graphql"]["space"]["endpoint"]) ``` This is the response for a space in eu-central: **Recorded response · REST · 200 OK** ```json { "space_id": "nv2_northwind_docs", "region_url": "https://eu-central.api.coevera.com", "space_version": 100, "server_time": "2026-10-15T09:00:00", "graphql": { "space": { "endpoint": "https://eu-central.api.coevera.com/api/v100/app/space/nv2_northwind_docs/graphql/public", "schema": "https://eu-central.api.coevera.com/api/v100/app/space/nv2_northwind_docs/graphql/public/schema.json" } }, "rest": { "space": { "endpoint": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs", "schema": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/openapi.json" } } } ``` **Recorded response · GraphQL · 200 OK** ```json { "space_id": "nv2_northwind_docs", "region_url": "https://eu-central.api.coevera.com", "space_version": 100, "server_time": "2026-10-15T09:00:00", "graphql": { "space": { "endpoint": "https://eu-central.api.coevera.com/api/v100/app/space/nv2_northwind_docs/graphql/public", "schema": "https://eu-central.api.coevera.com/api/v100/app/space/nv2_northwind_docs/graphql/public/schema.json" } }, "rest": { "space": { "endpoint": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs", "schema": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/openapi.json" } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ | Field | What it holds | | ------------------------------------------------ | -------------------------------------------------- | | `region_url` | The host of the space's region | | `space_version` | `100`, the platform version of every current space | | `server_time` | The server's clock, in UTC | | `rest.space.endpoint`, `rest.space.schema` | The REST base URL and the space's OpenAPI document | | `graphql.space.endpoint`, `graphql.space.schema` | The GraphQL endpoint and the public schema | An unknown space answers `404` with `ERROR_NO_ENTITY` (code `143`). A space that is not on the current platform answers `400` with `ERROR_UNKNOWN_VERSION` (code `7`). Ask a `coevera.com` host: the URLs in the answer use the domain of the host you ask. The [migration page](/support/migrating-from-pipeliner/) covers the former domain. ## Calling the wrong region > **It still works, just more slowly:** REST and GraphQL calls sent to another region's host are forwarded to the region that holds the > space. The forwarded call answers `200` with the normal body; there is no redirect, and the same > credentials work. Every call takes an extra hop. Set the right region in the context bar and in > your configuration. How much slower depends on where you call from. From Central Europe, a call to a space in eu-central takes about 0.2 s through its own host, about 1 s through the us-east host and 2.5 to 3.6 s through the ap-southeast host. Use the region the discovery endpoint returns. ## Limits - A space lives in exactly one region, and every URL carries its space ID. There is no API across spaces: a credential works in one space only. - Read the host from the discovery endpoint or from **Show Access**; do not hard-code a region per customer. - The MCP server for AI assistants runs in the United States for every space; see [Hosting and your data](/ai/hosting-and-data/). --- title: Permissions and scopes description: What an API application may read and change, on the Records, Administration and Misc tabs; how OAuth scopes map to them; and what a missing permission answers. canonical: https://developers.coevera.com/get-started/permissions-and-scopes/ section: get-started verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Permissions and scopes **Answer:** Each API application has its own permissions on three tabs, Records, Administration and Misc. Every entry is Full Access (Write), Read Only Access (Read) or No Access (Disabled). A call the application lacks permission for answers HTTP 400 with code 50, not 403. User role restrictions do not apply to applications, and a credential works in one space only. An API application's access is set per application, not per user, so every integration can have exactly the access it needs. ## Where to set them Open **Administration › Units, Users & Roles › Applications**, select the application and click **Edit Permissions**. The **Application Permissions** dialog has three tabs, and every entry on them takes one of three values: | In the dialog | API value | Meaning | | -------------------- | ---------- | --------------- | | **Full Access** | `Write` | Read and change | | **Read Only Access** | `Read` | Read only | | **No Access** | `Disabled` | No access | The API values are those of `ApiPermissionEnum` in the public GraphQL schema, where an application's permissions are the `permissions` field of `ApiAccessEntity`. ## What each tab covers | Tab | Entries in the dialog | API names (`permissions.…`) | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Records** | Accounts, Contacts, Leads, Opportunities, Tasks, Appointments, Emails, Messages, Projects, and one entry per custom entity | `records.accounts`, `.contacts`, `.leads`, `.opportunities`, `.tasks`, `.appointments`, `.emails`, `.messages`, `.projects`, `.customEntities` | | **Administration** | Units, Users & Roles; Pipelines; Currencies; Products & Prices; Common Lists; Fields & Forms; Settings; Automation Hub; Security | `administration.unitsAndRoles`, `.pipelines`, `.currencies`, `.productsAndPrices`, `.commonList`, `.fieldsAndForms`, `.settings`, `.automationHub`, `.security` | | **Misc** | Reports, Webhooks, Data, Documents, Others | `misc.reports`, `.webhooks`, `.data`, `.documents`, `.others` | The **Records** tab uses your space's names for the record types, which an admin can rename. An integration that registers [webhooks](/webhooks/overview/) needs **Full Access** to **Webhooks**, on the **Misc** tab. One that saves processes or e-mail sequences needs at least **Read Only Access** to **Emails** when their e-mails use e-mail templates, and to **Documents** when a sequence's e-mails attach documents; without it the save answers code 50. **Automation Hub** and **Security** go up to **Read Only Access**: the dialog does not offer **Full Access** for them. Only API applications of type Basic have **Edit Permissions**. An application that an AI connector created is of type OAuth and shows **View Permissions**, read only: its permissions come from the scopes the user approved. ## What a missing permission answers A call the application lacks the permission for answers HTTP `400` with code [`50`](/reference/errors/#code-50), `ERROR_NOT_ENOUGH_PERMISSIONS`. The API never answers `403`, so check the code in the body, not only the status ([errors](/concepts/errors/)). Fix it in **Edit Permissions**. In a batch, the whole call fails by default ([batch operations](/concepts/batch-operations/)). ## User role restrictions An application has no user role: its permissions are its own. The access to Task and Appointment types that a user role can restrict (release 6.4.0) has no effect on integrations, which keep using the types they are configured for. ## One space per credential An application belongs to one space, and its user name carries that space's ID. Its credentials are refused in any other space, whatever its permissions. An integration that works with several spaces needs an application in each. ## OAuth scopes The AI connectors sign in with OAuth, and the scopes the user approves become the permissions of an OAuth-type application: a `.full` scope becomes **Write** and a `.read` scope **Read**, on the matching entry. **Settings**, **Automation Hub** and **Security** can never be granted through OAuth, whatever the scopes. The scopes are listed in [OAuth and scopes](/ai/oauth-and-scopes/). ## Least privilege - Create one application per integration, so you can change or revoke one without touching the others. - Give **Read Only Access** where the integration only reads, and **No Access** to everything it does not use. - Review the applications list from time to time: its **Last usage** column shows when each application last called the API. ## Limits - Permissions are set per record type and per area. --- title: Plans and limits description: The monthly API call limit of each Coevera plan, page sizes, file sizes and what the API answers when it limits a client. canonical: https://developers.coevera.com/get-started/plans-and-limits/ section: get-started verification: behaviour lastVerified: 2026-10-16 publisher: Coevera --- # Plans and limits **Answer:** Every Coevera plan includes the API, with a monthly limit of 100,000 calls on Business, 150,000 on Enterprise and 350,000 on Unlimited (pricing page). A REST list returns at most 100 records, 30 by default; in GraphQL, send first, at most 100. Files are limited by the space's Attachment Size Limit, 30 MB by default. No per-minute limit is published. The numbers an integration has to stay within, and where each one comes from. ## API calls per month Every Coevera plan includes the API. The [pricing page](https://www.coevera.com/crm/pricing/) lists a monthly limit of API calls for each plan: | Plan | API calls per month | | ---------- | ------------------- | | Business | 100,000 | | Enterprise | 150,000 | | Unlimited | 350,000 | The pricing page is the source: when it changes, this page follows. It does not say what happens when a space goes over its limit. ## Page size A REST list request returns at most 100 records per page, and 30 when you do not set `first`. A `first` above 100 answers `400` with `ERROR_PARAMETER_INVALID` (code `117`) and the message `Limit must not be greater than 100`. Read more records by passing `page_info.end_cursor` as `after` until `has_next_page` is `false`, as [pagination](/concepts/pagination/) shows. In GraphQL, always send `first` to `getByCriteria`, at most 100, and page with `after` and `pageInfo.endCursor` the same way. ## File size Files are limited by the space's **Attachment Size Limit**, which an admin sets under **Administration › Settings › Global Settings**: 30 MB by default, and up to 200 MB (the [pricing page](https://www.coevera.com/crm/pricing/) and the [4.6.0 release notes](https://www.coevera.com/release-notes/coevera-v4-6-0/)). The same limit applies to a file you attach through the API, which is refused with `400`, code `117`, when it is larger. The API cannot read the setting, so ask the space's admin or stay under 30 MB. The [attach a document](/recipes/attach-document/) recipe uploads a file. Files are sent as base64, which adds about a third: a 30 MB file is about 40 MB of JSON. ## Rate limiting No per-minute or per-second limit is published for the REST and GraphQL API. A throttled request answers `429` with `ERROR_TOO_MANY_REQUESTS` (code `182`) and, when the limit computes one, a `Retry-After` header with the seconds to wait. Handle it in every integration: - wait for the seconds in `Retry-After`, then retry the same request; - without `Retry-After`, back off exponentially, for example 1, 2, 4 and 8 seconds; - do not retry a `401`: repeated failed sign-ins lock the credentials for a while, and every request answers `429` until the lock ends. [Rate limits and retries](/concepts/rate-limits-and-retries/) shows the retry in code, and [batch operations](/concepts/batch-operations/) how to write many records in fewer calls. ## Other platforms - The [Microsoft Power Platform connector](/integrations/power-platform/) is limited to 100 calls per connection per minute (Microsoft's connector reference). - The MCP server for AI assistants has a burst limit per token and a sustained limit per space ([its reference](https://mcp-server.coevera.com/doc#troubleshooting)); see [Limits and errors](/ai/limits-and-errors/). ## Limits - No per-minute limit is published, and the API returns no rate-limit headers other than `Retry-After`. - A REST page holds at most 100 records; larger reads take several pages. --- title: REST or GraphQL? description: The Coevera API has a REST and a GraphQL interface over the same data. How they differ in paths and naming, and which business actions only GraphQL has. canonical: https://developers.coevera.com/get-started/rest-or-graphql/ section: get-started verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # REST or GraphQL? **Answer:** Both reach the same data with the same credentials. REST suits create, read, update and delete, with kebab-case query parameters and snake_case bodies. GraphQL uses camelCase names and has the business actions REST lacks: qualifying a lead exists only there, while move, change owner and lost have a REST PATCH that skips the actions' extras. Every page of these docs shows both interfaces where both exist. They share the space, the records, the permissions and the API application's credentials; they differ in shape and in what they can do. ## Side by side | | REST | GraphQL | | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Endpoint | `/api/v100/rest/spaces/{space_id}/entities/{Entity}`, one path per entity | `POST /api/v100/app/space/{space_id}/graphql/public`, one endpoint; a `GET` answers `405` | | Query parameters | kebab-case: `load-only`, `order-by`, `filter-op`, `include-deleted` ([querying](/concepts/querying/)) | Arguments in camelCase: `first`, `after`, `orderBy`, `includeDeleted` | | Field names | snake_case, in bodies and responses: `owner_id`, `step_id`, `is_archived` | camelCase: `ownerId`, `stepId`, `isArchived` | | Lists | `success`, `total`, `page_info`, `data` ([pagination](/concepts/pagination/)) | `totalCount`, `pageInfo`, `edges` | | Date-times | A space between date and time: `2026-10-14 09:00:00+00:00` | A `T`: `2026-10-14T09:00:00+00:00` ([dates and time zones](/concepts/dates-and-time-zones/)) | | Writes | `POST`, `PATCH`, `DELETE` and [batch](/concepts/batch-operations/) endpoints per entity | Mutations such as `createAccount` | | Business actions | None; a `PATCH` of the field instead (below) | Qualify, move, change owner, archive and more | | Contract | The [OpenAPI document](/get-started/regions-and-base-urls/#base-urls), described in the [REST reference](/reference/rest/) | The public GraphQL schema, described in the [GraphQL reference](/reference/graphql/) | The [quickstart](/get-started/quickstart/) makes the same first call both ways, and the [downloads](/reference/downloads/) have both contracts as files. ## Business actions The CRM's actions on leads and opportunities are GraphQL mutations. REST has only create, read, update and delete, so for most actions it changes the field instead: | Action | GraphQL | What REST can do instead | | ------------ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Qualify | `qualifyLead` | Nothing. Qualifying converts the lead into an opportunity with the same id; REST cannot reproduce it. | | Move | `moveOpportunity`, `moveLead` | `PATCH` `step_id` | | Change owner | `changeOwnershipOpportunity`, and one per entity | `PATCH` `owner_id` | | Won | A move to the pipeline's last step; there is no separate won action | `PATCH` `step_id` to the last step | | Lost | `archiveOpportunity`: archiving; there is no separate lost action | `PATCH` `is_archived` | A REST `PATCH` changes the record and writes the same history entries and webhook events as the GraphQL action. It skips what the action adds: - a memo (`memoText`); - completing activities (`completeActivityIds`); - accepting or archiving quotes (`acceptQuoteIds`, `archiveQuoteIds`); - keeping the old owner as an editor (`keepOwnerAsEditor`); - transferring linked records (`transferLinkedRecords`); - the lost form's required fields. Use the GraphQL action when you need any of these. The [lead lifecycle](/recipes/lead-lifecycle/) recipe qualifies a lead, moves the opportunity and changes its owner with them, and [create an account with contacts](/recipes/create-account-with-contacts/) shows the same writes in both APIs. ## Choosing - **Synchronising records** (create, read, update, delete, batches): REST. Every entity has the same operations, which makes it the steadier choice for long-lived integrations. - **Business actions** (qualify, move with a memo, change owner with linked records): GraphQL. - **Fetching related records in one call**: either. REST has `expand`; GraphQL selects nested fields. Removals from the public GraphQL schema are announced in the [API changelog](/changelog/api/) before they happen. For long-lived integrations, prefer REST for create, read, update and delete, and GraphQL for the business actions REST does not have. ## Limits - REST has no action endpoints: no qualify, no move with a memo, no change of owner that transfers linked records. - The public GraphQL schema is the one at `…/graphql/public`. The CRM's own app schema is a different, internal one: do not build on it. --- title: Tools and downloads description: The OpenAPI file, the Postman collection and the GraphQL schema for the Coevera API, your space's own OpenAPI document, and how to use them in Postman and Insomnia. canonical: https://developers.coevera.com/get-started/tools/ section: get-started verification: behaviour lastVerified: 2026-10-16 publisher: Coevera --- # Tools and downloads **Answer:** The API reference offers an OpenAPI 3.1 file with Coevera hosts, a Postman collection with a Coevera environment, and the public GraphQL schema as SDL. Your space's own OpenAPI document, with its custom fields, is openapi.json under your REST base URL and needs your credentials. Postman imports the collection; Insomnia imports the OpenAPI file. There is no SDK. You can call the API from any HTTP client. These files save typing: they describe every operation, and the Postman collection is ready to send once you fill in your credentials. ## The downloads The REST reference's [Downloads](/reference/downloads/) page offers the OpenAPI file and the Postman collection, and the [GraphQL overview](/reference/graphql/) the schema as SDL, all built from the same pinned contract as the reference itself: | File | What it holds | Use it in | | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | [OpenAPI 3.1 (JSON)](/openapi/coevera-rest.json) | Every REST operation, with the Coevera hosts as `{region}` and `{space_id}` server variables, the Basic security scheme, and the errors the API really returns | Insomnia, code generators, linters, AI tools | | Postman [collection](/postman/coevera-rest.postman_collection.json) and [environment](/postman/coevera.postman_environment.json) | Every REST operation, in folders by entity family, with Basic auth from the environment. The environment, **Coevera**, has the variables `region`, `space_id`, `key` and `secret`; `key` and `secret` ship empty. | Postman, Insomnia | | [GraphQL schema (SDL)](/graphql/schema.graphql) | The public GraphQL schema for integrators | Code generation, editor autocomplete, linting | Every build validates the OpenAPI file with Scalar's OpenAPI parser and stops on any error. The collection follows Postman's collection format v2.1.0, and the SDL is printed by graphql-js from the public schema. These files describe the standard data model. Custom entities appear under one generic path, `/entities/{custom_entity_api_name}`, and your custom fields are only in your space's own document. ## Your space's own documents Two documents come from the API itself, for your space: ```text https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}/openapi.json https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public/schema.json ``` - **`openapi.json`** is built for your space: its entities carry your custom fields. It needs the same Basic credentials as any API call and answers `401` without them. Its `servers` list hosts on the former domain; call `https://{region}.api.coevera.com` instead. - **`schema.json`** is the public GraphQL schema as an introspection result, in JSON. It needs no credentials. The [discovery endpoint](/get-started/regions-and-base-urls/#find-a-spaces-region) returns both URLs for a space. ## Postman 1. Download the [collection](/postman/coevera-rest.postman_collection.json) and the [environment](/postman/coevera.postman_environment.json) from the [downloads](/reference/downloads/#postman). 2. In your Postman workspace, click **Use resources or import**, then **Import**, and select both files. 3. Select the **Coevera** environment and fill in `region`, `space_id`, `key` (the application's **User Name**) and `secret` (its **Password**). `key` and `secret` are secret variables; do not share or export the environment with them filled in. 4. Send any request in the collection: it takes Basic auth from `key` and `secret`, and its base URL from `region` and `space_id`. The menu names are those of [Postman's import documentation](https://learning.postman.com/docs/getting-started/importing-and-exporting/importing-data/). ## Insomnia In your Insomnia workspace, click **Import**, choose **File**, select the OpenAPI file or the Postman collection, click **Scan**, then **Import**. Insomnia imports OpenAPI 3.0 and 3.1 and Postman collections v2.0 and v2.1. An imported OpenAPI file opens as a spec: click **Generate** and choose **Requests** to get requests you can send. Then set the auth to Basic with the application's **User Name** and **Password**, and set the base URL to your region and space. The steps and formats are those of Kong's Insomnia documentation, [import an API spec](https://developer.konghq.com/how-to/import-an-api-spec/) and [import and export](https://developer.konghq.com/insomnia/import-export/). ## GraphQL clients Send `POST` requests to your space's GraphQL endpoint with Basic auth, as in the [quickstart](/get-started/quickstart/). Load the schema from the SDL download or from your space's `schema.json` for autocomplete. ## Limits - Coevera publishes no SDK. Generate a client from the OpenAPI file, or call the API over plain HTTP as the samples on this site do. - This site has no console for sending requests; use Postman, Insomnia or the samples. - The import steps follow the Postman and Insomnia documentation. The files were validated against the formats those apps import, not imported in the apps themselves. --- title: Data model description: How Coevera records fit together. Entities and their plural paths, owners and sales units, the relation rows that link records, and where your own fields and entities are described. canonical: https://developers.coevera.com/concepts/data-model/ section: concepts verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Data model **Answer:** Each record type is an entity with a plural path, such as /entities/Accounts. Records point at each other through id fields such as owner_id, and through relation entities such as ContactAccountRelations. Every record except Leads and Tasks has an owner, a Client (user). Your custom fields and custom entities are described only in your space's own spec. Every integration starts from the same questions: which entity holds the data, how two records are linked, and who owns them. The names below are the API's; the [glossary](/concepts/glossary/) pairs them with the names the app shows, and the [REST API reference](/reference/rest/) has a page for every entity. ## Entities and their paths Each record type is an entity. REST paths use the plural name, `/entities/Accounts`, `/entities/Opportunities`, `/entities/OpptyProductRelations`; the GraphQL repository is the singular name in camelCase, `entities { account { … } }`. A singular or misspelt path is refused with HTTP 404, code 2006 `ERROR_UNKNOWN_TYPE`. | Entity | What it holds | | ----------------------- | -------------------------------------------------------------------------- | | `Accounts` | Companies and organisations | | `Contacts` | People, usually linked to accounts | | `Leads` | Unqualified sales opportunities | | `Opportunities` | Qualified deals in a pipeline, on a step (`Steps`) of a `Pipelines` record | | `OpptyProductRelations` | Product line items on an opportunity; the catalogue is `Products` | | `Quotes` | Quotes for an opportunity | | `Projects` | Projects | | `Tasks`, `Appointments` | The two kinds of activity | | `Notes` | Notes attached to a record | | `Emails`, `Memos` | Logged e-mails, and internal messages (shown in the app as **Message**) | | `CloudObjects` | Documents and files; `CloudObjectRelations` attaches them to records | | `Clients` | The space's users; their roles are `MasterRights` | | `SalesUnits` | Sales units, which group users and records | | `Data` | The options of dropdown fields | There is no `Messages` entity: `/entities/Messages` answers 404, code 2006. Logged e-mails are `Emails` and internal messages are `Memos`, both linked to records through `MessageRelations`. Leads and opportunities share one base, so their relation fields are called `lead_oppty_id`. ## Owners and sales units `owner_id` (shown in the app as **Owner**) is required on every record except Leads and Tasks: those two may be unassigned, while Appointments, Accounts, Contacts, Opportunities, Quotes, Projects and custom entity records always have an owner. A lead still needs a sales unit: send `owner_id`, or `unit_id` alone for an unassigned lead; without either, the create answers 422. The owner is a `Clients` record, a user of the space. Users are looked up by `email`, not `email1` as on contacts and accounts: `filter[email]=dana.whitfield@example.com` on `/entities/Clients`. `unit_id` (shown as **Sales unit**) places the record in a `SalesUnits` record, which decides who else in the space sees it. ## How records link A record points at another one in two ways: - **An id field**, such as `owner_id` or `account_id`, holds the other record's ID. Write it to link records; filter on it to find them. - **A relation field** with the bare name, such as `owner`, returns the related record's URL. Add `expand=owner` to a list request to get the record itself ([querying](/concepts/querying/#load-only-and-expand)). Many-to-many links are records of their own, in relation entities: | Relation entity | Links | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ContactAccountRelations` | Contacts and accounts, with `position` and `is_primary`; the [create an account with contacts](/recipes/create-account-with-contacts/) recipe writes them | | `LeadOpptyAccountRelations`, `LeadOpptyContactRelations` | Leads and opportunities, and their accounts and contacts | | `QuoteAccountRelations`, `QuoteContactRelations` | Quotes, and their accounts and contacts | | `ActivityRelations` | Tasks and appointments, and the records they are about | | `MessageRelations` | E-mails and messages, and the records they are about | | `CloudObjectRelations` | Documents, and the records they are attached to | Relation records outlive their records: deleting a contact or an account leaves its `ContactAccountRelations` active, still pointing at the deleted record. Delete them on their own ([batch operations](/concepts/batch-operations/#batch-endpoints)). ## Your own fields and entities The published spec describes the standard entities and fields every space has. Your space's custom fields (API names starting with `cf_`) and custom entities (`CE_` names) are in your space's own spec, served with your credentials at `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}/openapi.json`, and in the [Fields](/concepts/fields-and-custom-fields/) and `Entities` endpoints. ## Limits | Limit | Details | | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | Owner | Required on every record except Leads and Tasks; a lead without an owner needs a `unit_id` | | Custom fields and entities | Not in the published spec; only in your space's own spec and the Fields and Entities endpoints | | Relation records | Not deleted with the records they link | | Entity names | `Messages`, `Documents` and `Users` are not entities: use `Emails` and `Memos`, `CloudObjects` and `Clients` | --- title: "Glossary: UI and API names" description: The names the Coevera app shows next to the names the API uses, for the entities and fields whose names differ, and why integrations key on API names. canonical: https://developers.coevera.com/concepts/glossary/ section: concepts verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Glossary: UI and API names **Answer:** The app and the API name several things differently: users are Clients, roles are MasterRights, documents are CloudObjects, messages are Memos, sales units are SalesUnits and product line items are OpptyProductRelations. Admins can rename entities and fields in the app; the API names stay, so integrations key on API names and look labels up when they need them. These docs give the API name first and the app's name in bold on first use: `owner_id` (shown in the app as **Owner**). This table is for the other direction: you know a screen in the app and need the entity behind it. ## Entities | In the app | In the API (REST path, GraphQL repository) | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **User** | [`Clients`](/reference/rest/clients/), [`client`](/reference/graphql/client/) | | **Role** | [`MasterRights`](/reference/rest/master-rights/), [`masterRight`](/reference/graphql/master-right/) | | **Sales Unit** (**Unit**) | [`SalesUnits`](/reference/rest/sales-units/), [`salesUnit`](/reference/graphql/sales-unit/) | | **Document** | [`CloudObjects`](/reference/rest/cloud-objects/), [`cloudObject`](/reference/graphql/cloud-object/); attached to records through `CloudObjectRelations` | | **Folder** (of documents) | [`CloudObjectFolders`](/reference/rest/cloud-object-folders/), [`cloudObjectFolder`](/reference/graphql/cloud-object-folder/) | | **Message** | [`Memos`](/reference/rest/memos/), [`memo`](/reference/graphql/memo/); its comments are `MemoComments` | | **Note** | [`Notes`](/reference/rest/notes/), [`note`](/reference/graphql/note/) | | **Email** | [`Emails`](/reference/rest/emails/), [`email`](/reference/graphql/email/) | | **Text Message** | [`TextMessages`](/reference/rest/text-messages/), [`textMessage`](/reference/graphql/text-message/) | | **Product Line Item** | [`OpptyProductRelations`](/reference/rest/oppty-product-relations/), [`opptyProductRelation`](/reference/graphql/oppty-product-relation/) | | **Opportunity** | [`Opportunities`](/reference/rest/opportunities/), [`opportunity`](/reference/graphql/opportunity/); IDs that may point at a lead or an opportunity are `lead_oppty_id` | | **Step** (of a pipeline or a process) | [`Steps`](/reference/rest/steps/), [`step`](/reference/graphql/step/) | | **Step sales activities** | [`StepChecklists`](/reference/rest/step-checklists/), [`stepChecklist`](/reference/graphql/step-checklist/) | | **Velocity** | [`Timeframes`](/reference/rest/timeframes/), [`timeframe`](/reference/graphql/timeframe/) | | **Field Auto Number** | [`FieldSequences`](/reference/rest/field-sequences/), [`fieldSequence`](/reference/graphql/field-sequence/) | | **Recurrence** | `OpptyRecurrences` and `TaskRecurrences` | | Dropdown options | [`Data`](/reference/rest/data/), [`data`](/reference/graphql/data/) | | **Space** | The space ID in every URL, `{space_id}` | The app files administration of users and roles under **Administration › Units, Users & Roles**, and API applications under **Units, Users & Roles › Applications**. ## Fields | In the app | In the API | | -------------------- | ------------------------------------------------------------------- | | **Owner** | `owner_id` (the ID), `owner` (the related record) | | **Sales unit** | `unit_id`, `unit` | | **Role** (of a user) | `master_right_id` on `Clients` | | A user's e-mail | `email` on `Clients`; records use `email1` to `email5` | | A record's phone | `phone1` to `phone5` | | Custom fields | `cf_` plus the field's name at creation, such as `cf_customer_tier` | ## Renamed entities and fields An admin can rename entities such as Accounts, Contacts, Leads, Opportunities, Projects and Quotes, and any field, so the labels in one space differ from another's. The API names do not follow: - `entity_name` on `/entities/Entities` is the stable name (`Account`), and `name_singular` and `name_plural` are the labels an admin set, or `null` while the default applies. - A field keeps its `api_name` when it is renamed. A field that is deleted and created again under the same name gets a new one, with a number added (`cf_customer_tier1`). Key integrations on API names and read labels from those two endpoints when you show them. The [fields page](/concepts/fields-and-custom-fields/#find-a-fields-api-name) shows how. ## Limits | Limit | Details | | ---------- | --------------------------------------------------------------------------------------------------------------------- | | Labels | Per space: an admin's renames show in the app, never in API names | | This table | Covers the names that differ; an entity not listed here has the same name in both, such as **Account** and `Accounts` | --- title: Fields and custom fields description: Read an entity's fields and dropdown options from the Fields endpoint, create custom fields, and update dropdown options without deleting the ones you leave out. canonical: https://developers.coevera.com/concepts/fields-and-custom-fields/ section: concepts verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Fields and custom fields **Answer:** GET /entities/Fields with filter[entity_name]=Account lists an entity's fields with their api_name, type and options. Create a custom field with POST /entities/Fields and change it with PATCH /entities/Fields/{id}. A data_set you send replaces the whole option list: an option you leave out is deleted. Key integrations on api_name, never on the label. Fields are records too. The `Fields` entity describes every field of every entity in your space, the standard ones and your custom ones, so an integration can find a field's API name and its dropdown options at run time instead of hard-coding them. ## Find a field's API name List an entity's fields with [`GET /entities/Fields`](/reference/rest/fields/#list) and these parameters: | Parameter | Value | Why | | --------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filter[entity_name]` | `Account` | The singular entity name, such as `Account`, `Opportunity`, `OpptyProductRelation` or `Quote`. The shared parents `LeadOppty` and `Activity` have no fields of their own: ask for `Lead` or `Opportunity`, `Task` or `Appointment`. | | `expand` | `data_set` | Returns a dropdown's options with their `id` and `option_name` | | `load-only` | `id,name,api_name,type_id,data_set.id,data_set.option_name` | Keeps the response small | | `first` | `100` | An entity can have more fields than the default page of 30 | Each field has a `name`, the label the app shows, and an `api_name`, the key in the entity's records. Custom fields have API names that start with `cf_`; a dropdown's ends with `_id`, because the record stores the option's ID. The API name is made from the field's name when the field is created, and then it stays: - Renaming a field changes `name`, not `api_name`. - A field deleted and created again under the same name gets a new API name with a number added: `cf_customer_tier` becomes `cf_customer_tier1`. An integration keyed on the old name stops finding the field. So store `api_name`, never the label, and do not recreate a field an integration uses. ## Create a custom field [`POST /entities/Fields`](/reference/rest/fields/#create) creates a field. The body names the entity in `entity_name`, the label in `name` and the type in `type_id`: | `type_id` | Field | | -------------------------------------------------- | ---------------------------------------------------------------------- | | `input`, `text_area` | Single-line and long text | | `integer`, `float`, `currency`, `currency_foreign` | Numbers and money | | `date`, `datetime` | A date, a date and time | | `checkbox` | True or false | | `dropdown`, `radio`, `multiselect_checkbox` | Options from `data_set`: an array of `{ "option_name", "calc_value" }` | | `email`, `phone`, `url` | Validated text | | `sequence` | An auto-number | Give every option a `calc_value`, the number it counts as in calculated fields. ## Update dropdown options Update a field with [`PATCH /entities/Fields/{id}`](/reference/rest/fields/#update). A PATCH sent to `/entities/{id}` without `Fields` in the path answers HTTP 405. > **data_set replaces every option:** The `data_set` you send becomes the field's whole option list. Send every option you keep, each > existing one with its `id`, and new ones without an `id`. An option you leave out is deleted. For example, a dropdown with the options Gold, Silver and Bronze, updated with a `data_set` of Gold (by its `id`) and a new Platinum, reads back with `expand=data_set` as Gold and Platinum: Silver and Bronze are deleted. ## Limits | Limit | Details | | ------------- | ------------------------------------------------------------------------------- | | `entity_name` | Concrete entities only; `LeadOppty` and `Activity` have no fields | | `data_set` | Replaces the whole list on every write | | API names | Fixed at creation; a re-created field gets a new one | | GraphQL | This page covers REST; the public GraphQL schema has its own `field` repository | --- title: Custom entities description: Custom entities have API names that start with CE_. Use them like built-in entities, find their names in the Entities endpoint, and read their fields from your space's own spec. canonical: https://developers.coevera.com/concepts/custom-entities/ section: concepts verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Custom entities **Answer:** A custom entity's API name is CE_ plus its name in CamelCase, such as CE_Asset, and its types are CE_AssetType. Use /entities/CE_Asset like a built-in entity; /entities/CustomEntities is refused. Read the names from the Entities endpoint and your space's own spec rather than from labels, which admins can rename. An admin can add entities of their own, such as assets or contracts, next to Accounts and Contacts. They work like the built-in entities, under names you have to look up. ## API names A custom entity's API name is `CE_` followed by the name the admin gave it, in CamelCase: **Asset** becomes `CE_Asset`. Its types, the layouts its records can use, are a second entity with `Type` added: `CE_AssetType`. | Request | Path | | ------------------------ | ------------------------------------------------------------------ | | List, create | `/entities/CE_Asset` | | Read, update, delete one | `/entities/CE_Asset/{id}` | | Batches | `/entities/CE_Asset/batch-modify`, `batch-delete`, `batch-restore` | | Its types | `/entities/CE_AssetType` | The prefix is part of the name. `/entities/CustomEntities` is refused with HTTP 404, code 143 `ERROR_NO_ENTITY`: "To work with custom entities, you have to specify concrete custom entity api name instead of CustomEntity/CustomEntities". A custom entity record needs an `owner_id`, like most built-in records ([data model](/concepts/data-model/#owners-and-sales-units)). ## Find your custom entities `GET /entities/Entities` lists every entity of the space. Ask for the custom ones by their prefix: ```text GET /entities/Entities?filter[entity_name]=CE_&filter-op[entity_name]=starts&load-only=entity_name,name_singular,name_plural ``` `entity_name` is the stable name to use in paths. `name_singular` and `name_plural` are the labels an admin set, and are `null` while the default applies. A custom entity's fields come from the [Fields endpoint](/concepts/fields-and-custom-fields/#find-a-fields-api-name) and from your space's own spec, `/api/v100/rest/spaces/{space_id}/openapi.json`. The published spec has only the path templates `/entities/{custom_entity_api_name}` and `/entities/{custom_entity_type_api_name}`, which the reference shows as [custom entity records](/reference/rest/custom-entities/) and [custom entity types](/reference/rest/custom-entity-types/). ## Labels change, API names are what you key on Admins rename entities and fields in the app, so a label that matched yesterday may not match today. An integration that derives a path from a label, or looks a field up by its label, breaks on the first rename. Read `entity_name` and the fields' `api_name` once, at start-up or when you configure the integration, and use those. ## Limits | Limit | Details | | ----------------- | ------------------------------------------------------------------------------------------------ | | The query builder | Offers the standard entities only; your custom entities and fields are not in the published spec | | Renaming | Read `entity_name` from the Entities endpoint rather than deriving it from the entity's label | --- title: Querying and the query builder description: Narrow a list with filter and filter-op, order it with order-by, and return only the fields you need with load-only and expand. Build the request below, in REST and GraphQL. canonical: https://developers.coevera.com/concepts/querying/ section: concepts verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Querying and the query builder **Answer:** REST lists take filter[field]=value with filter-op[field]=operator (eq by default), order-by (a leading - sorts descending), load-only and expand. A raw comma separates OR values; %2C is a literal comma. A repeated parameter is refused with HTTP 400, code 117. GraphQL takes the same conditions as a filter object on getByCriteria. Every list request, `GET /entities/{Entity}`, takes the same parameters to filter, sort and trim the records; each entity's page in the [REST reference](/reference/rest/), such as [list accounts](/reference/rest/accounts/#list), lists them with its fields. Build a query with the builder, or read on for the rules it follows. ## Build a query **Query builder** (interactive on the web page): pick an entity (Accounts, Contacts, Leads, Opportunities, Products, Quotes, Projects, Tasks, Appointments, Notes, Clients), add filters, a sort, a page size and the fields to return, and it writes the REST request and the same query in GraphQL. It makes no API call. Its default query for Accounts: ```text https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}/entities/Accounts?filter[name]=north&filter-op[name]=icontains&filter[city]=Seattle&filter[modified]=2026-10-01&filter-op[modified]=gte&order-by=-modified&load-only=id,name,city,modified&first=30 ``` The same query in GraphQL, sent as POST to `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`: ```graphql query { entities { account { getByCriteria( first: 30 filter: { AND: [ { name: { icontains: ["north"] } } { city: { eq: ["Seattle"] } } { modified: { gte: "2026-10-01" } } ] } orderBy: [{ modified: Desc }] ) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name city modified } } } } } } ``` Filterable fields of Accounts in the builder (REST name, type; GraphQL name where GraphQL can filter it): `account_class` option, `accountClass`; `account_type_id` ID, `accountTypeId`; `address` text, `address`; `city` text, `city`; `comments` text, `comments`; `country` text, `country`; `created` date-time, `created`; `currency_exchange_rates_list_id` ID, `currencyExchangeRatesListId`; `customer_type_id` ID, `customerTypeId`; `email1` text, `email1`; `email2` text, `email2`; `email3` text, `email3`; `email4` text, `email4`; `email5` text, `email5`; `health_status` number, `healthStatus`; `home_page` text, `homePage`; `id` ID, `id`; `industry_id` ID, `industryId`; `is_archived` true or false, `isArchived`; `is_deleted` true or false, `isDeleted`; `last_ai_account_hierarchy_applied` date-time, `lastAiAccountHierarchyApplied`; `last_ai_org_chart_applied` date-time, `lastAiOrgChartApplied`; `modified` date-time, `modified`; `name` text, `name`; `owner_id` ID, `ownerId`; `parent_account_id` ID, `parentAccountId`; `parent_account_relation_type_id` ID, `parentAccountRelationTypeId`; `phone1` text, `phone1`; `phone2` text, `phone2`; `phone3` text, `phone3`; `phone4` text, `phone4`; `phone5` text, `phone5`; `picture_id` ID, `pictureId`; `quick_parent_account_name` text, `quickParentAccountName`; `revision` number, `revision`; `share_mode` option, `shareMode`; `state_province` text, `stateProvince`; `unit_id` ID, `unitId`; `zip_code` text, `zipCode`. - Operators for option fields: `eq`, `not-eq` - Operators for ID fields: `eq`, `not-eq`, `null`, `not-null` - Operators for text fields: `eq`, `ieq`, `not-eq`, `not-ieq`, `contains`, `icontains`, `starts`, `istarts`, `ends`, `iends`, `null`, `not-null`, `empty`, `not-empty` - Operators for date-time fields: `eq`, `not-eq`, `gt`, `gte`, `lt`, `lte`, `between`, `null`, `not-null` - Operators for number fields: `eq`, `not-eq`, `gt`, `gte`, `lt`, `lte`, `between`, `null`, `not-null` - Operators for true or false fields: `eq` The builder offers the standard entities and their fields from the published API spec. Your own custom fields and custom entities are not in it: they are in your space's own spec ([data model](/concepts/data-model/#your-own-fields-and-entities)). ## A query in code This query lists the accounts in Seattle or Portland changed since 2026-01-01, newest change first, with four fields each: **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # -d sends each value as written, so the comma in filter[city] stays raw: Seattle OR Portland. curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ -d "filter[city]=Seattle,Portland" \ -d "filter[modified]=2026-01-01" \ -d "filter-op[modified]=gte" \ -d order-by=-modified \ -d load-only=id,name,city,modified \ -d first=10 \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); // Each OR value is encoded on its own and the values are joined with a raw comma. Do not build // this with URLSearchParams: it sends the comma as %2C, which is a literal comma. const cities = ["Seattle", "Portland"].map(encodeURIComponent).join(","); const query = [ `filter[city]=${cities}`, "filter[modified]=2026-01-01", "filter-op[modified]=gte", "order-by=-modified", "load-only=id,name,city,modified", "first=10", ].join("&"); const res = await fetch(`${base}/entities/Accounts?${query}`, { headers: { Authorization: `Basic ${auth}` } }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${body.message}`); console.log(`${body.total} matching accounts:`, JSON.stringify(body.data, null, 2)); ``` **REST · Python** ```python import json import os import sys from urllib.parse import quote import requests 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"]) # Each OR value is encoded on its own and the values are joined with a raw comma. Do not pass # the filter in params=: requests sends the comma as %2C, which is a literal comma. cities = ",".join(quote(city, safe="") for city in ["Seattle", "Portland"]) query = "&".join([ f"filter[city]={cities}", "filter[modified]=2026-01-01", "filter-op[modified]=gte", "order-by=-modified", "load-only=id,name,city,modified", "first=10", ]) response = session.get(f"{BASE}/entities/Accounts?{query}", timeout=30) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {body.get('message')}") print(body.get("total"), "matching accounts:", json.dumps(body["data"], indent=2)) ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- <<'JSON' { "query": "{ entities { account { getByCriteria(first: 10, filter: { AND: [{ city: { eq: [\"Seattle\", \"Portland\"] } }, { modified: { gte: \"2026-01-01T00:00:00\" } }] }, orderBy: [{ modified: Desc }]) { totalCount edges { node { id name city modified } } } } } }" } JSON ``` **GraphQL · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); const ACCOUNTS = `{ entities { account { getByCriteria( first: 10 filter: { AND: [ { city: { eq: ["Seattle", "Portland"] } } { modified: { gte: "2026-01-01T00:00:00" } } ] } orderBy: [{ modified: Desc }] ) { totalCount edges { node { id name city modified } } } } } }`; const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" }, body: JSON.stringify({ query: ACCOUNTS }), }); const body = await res.json(); // GraphQL reports errors in the body, often with HTTP 200: check both. if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`); const accounts = body.data.entities.account.getByCriteria; console.log(`${accounts.totalCount} matching accounts:`, JSON.stringify(accounts.edges, null, 2)); ``` **GraphQL · Python** ```python import json import os import sys import requests ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) AUTH = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) ACCOUNTS = """ { entities { account { getByCriteria( first: 10 filter: { AND: [ { city: { eq: ["Seattle", "Portland"] } } { modified: { gte: "2026-01-01T00:00:00" } } ] } orderBy: [{ modified: Desc }] ) { totalCount edges { node { id name city modified } } } } } } """ response = requests.post(ENDPOINT, json={"query": ACCOUNTS}, auth=AUTH, timeout=30) body = response.json() # GraphQL reports errors in the body, often with HTTP 200: check both. if not response.ok or body.get("errors"): sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}") accounts = body["data"]["entities"]["account"]["getByCriteria"] print(accounts["totalCount"], "matching accounts:", json.dumps(accounts["edges"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 2, "page_info": { "start_cursor": "WyIyMDI2LTEwLTE1VDA1OjA2OjM2Ljk2NTEwOCswMDowMCIsICIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIyMDI2LTEwLTE1VDA1OjA2OjM1LjU4NDMyNiswMDowMCIsICIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0a000000-0000-4000-a000-000000000001", "modified": "2026-10-15 09:00:01.380782+00:00", "city": "Portland", "name": "Wide World Importers" }, { "id": "0a000000-0000-4000-a000-000000000002", "modified": "2026-10-15 09:00:00.000000+00:00", "city": "Seattle", "name": "Northwind Analytics" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "account": { "getByCriteria": { "totalCount": 2, "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Wide World Importers", "city": "Portland", "modified": "2026-10-15T09:00:01.380782+00:00" } }, { "node": { "id": "0a000000-0000-4000-a000-000000000002", "name": "Northwind Analytics", "city": "Seattle", "modified": "2026-10-15T09:00:00.000000+00:00" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## Filter - `filter[]=` keeps the records whose field matches; `filter-op[]` names the operator, `eq` when you leave it out. Several filters must all match. - A comma-separated value is an OR for the list operators: `filter[city]=Seattle,Portland` matches either city. Send a comma that belongs to the value as `%2C`: `filter[city]=Seattle%2CPortland` looks for one city called "Seattle,Portland". - Many HTTP libraries encode every comma as `%2C`, among them `URLSearchParams` and the `params` of Python's `requests`, which turns an OR list into one value that matches nothing. Encode each value on its own and join them with a raw comma, as the sample does. - A raw `+` in a query string arrives as a space. Encode values, so `+` is sent as `%2B`. - Filter on a related record's field with a dotted path: `filter[owner.email]=dana.whitfield@example.com`. A path reaches one related record, not that record's relations: `filter[step.pipeline_id]` finds the opportunities in one pipeline, while `filter[step.pipeline.name]` is refused with HTTP 400, code 117. An opportunity's `primary_account` cannot be filtered on either (code 117): `filter[account_relations.account_id]` finds the opportunities linked to an account, primary or not. - Send each parameter once. A repeated key, such as `filter[city]` twice, is refused with HTTP 400, code 117 `ERROR_PARAMETER_INVALID` ("Parameter filter\[city] is duplicated within the query."): put the values in one list instead. An unknown parameter is refused with HTTP 400, code 118 `ERROR_PARAMETER_UNKNOWN`. ### Operators | Operator | Meaning | Value | | ---------------------------------------- | ----------------------------------------------------------------------- | ------------------------------- | | `eq`, `ieq`, `not-eq`, `not-ieq` | Equal, equal ignoring case, and their negations | One or more (OR) | | `contains`, `icontains` | Contains the value; `icontains` ignores case | One or more | | `starts`, `istarts`, `ends`, `iends` | Starts or ends with the value; the `i` forms ignore case | One or more | | `gt`, `gte`, `lt`, `lte` | Greater or less than, or equal; for numbers, dates, date-times and text | Exactly one | | `between` | Inside a range; both ends are included | Two: `filter[revision]=355,356` | | `null`, `not-null`, `empty`, `not-empty` | Has no value, has one; empty text, non-empty text | None | - These are all the operators. Any other, such as `not-contains`, is refused with HTTP 400, code 117\. - A second value for `gt`, `gte`, `lt` or `lte` is refused with HTTP 400, code 117, and so is a value for `null`, `not-null`, `empty` or `not-empty`. - A text field that was never set is empty, not null: `filter-op[address]=empty` finds it, `filter-op[address]=null` does not. - Dates and date-times are compared as the [dates page](/concepts/dates-and-time-zones/#send-date-times) describes: a value without an offset is UTC. ## Sort `order-by=created,-modified` sorts by `created` ascending, then by `modified` descending. A dotted path sorts by a related record's field: `order-by=owner.name`. ## load-only and expand - `load-only=id,name,owner.id` returns only those fields, which keeps pages small. It accepts a raw or an encoded comma. - `expand=owner` returns the related record itself instead of its URL. With `load-only`, name the relation there too, or it is left out: `load-only=id,name,owner&expand=owner` returns the whole owner, `load-only=id,name,owner.name&expand=owner` only the owner's name, and `load-only=id,name&expand=owner` no owner at all. - `include-deleted=true` adds soft-deleted records to a list. - Get-by-ID, `GET /entities/{Entity}/{id}`, takes `expand` and nothing else: `load-only` and `include-deleted` there are refused with HTTP 400, code 118. `include-deleted` works on lists only. ## The same query in GraphQL A repository's `getByCriteria` takes the conditions as a `filter` object, `orderBy`, `first`, `after` and `includeDeleted`: - Field names are camelCase: `owner_id` is `ownerId`. - A condition is `{ field: { operator: values } }`. `eq` and the text operators take a list, which is an OR; `gt`, `gte`, `lt` and `lte` take one value; `between` two. - Combine conditions with `AND`, `OR` and `NOT`. There are no `not-…` operators: wrap the condition in `NOT`. `not-null` is `{ field: { null: false } }`. - Option fields take the GraphQL enum value where REST takes a number: REST `filter[account_class]=2` is `{ accountClass: { eq: [ClassA] } }`. - GraphQL selects only the fields you name in `edges { node { … } }`; there is no "every field". - Not every field REST filters on can be filtered in GraphQL. Opportunities' `closing_date`, for example, has no GraphQL filter. The builder says so when it leaves a condition out. > **MCP filters look different:** The MCP server uses its own filter syntax, and `limit` instead of `first`. See > [filters and pagination in MCP](/ai/filters-and-pagination/). ## Limits | Limit | Value | What happens | | -------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- | | One key per parameter | `filter[field]`, `filter-op[field]` once each | A repeated key: HTTP 400, code 117 | | Unknown parameters | Not accepted | HTTP 400, code 118 | | OR across fields | Not in REST | REST combines different fields with AND only; GraphQL has `OR` | | Dotted paths | One related record deep | `filter[step.pipeline.name]`: HTTP 400, code 117 | | Values per operator | `gt`, `gte`, `lt`, `lte`: one; `between`: two; `null` and `empty` family: none | Otherwise HTTP 400, code 117 | | Page size | `first` up to 100 | See [pagination](/concepts/pagination/) | | Custom fields and entities | Not in the builder | Use your space's own spec | --- title: Pagination description: Lists return one page at a time. Ask for up to 100 records with first, then follow page_info.end_cursor with after until has_next_page is false. Cursors must be URL-encoded. canonical: https://developers.coevera.com/concepts/pagination/ section: concepts verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Pagination **Answer:** Send first (default 30, maximum 100) and read page_info in the response. While has_next_page is true, send page_info.end_cursor as after, URL-encoded. In GraphQL the fields are pageInfo.hasNextPage and endCursor; always send first to getByCriteria, at most 100. A list request returns one page of records, the total, and the cursors that fetch the pages around it. Loop until the response says there is no next page. Every list operation in the [REST reference](/reference/rest/), such as [list accounts](/reference/rest/accounts/#list), takes the parameters below; the [pipeline report](/recipes/pipeline-report/) recipe pages through opportunities with a filter and `expand`. ## How cursors work A cursor is an opaque string that marks a position in the result. Keep the same filter and sort while you follow it, and never build or parse a cursor yourself. | Parameter | Meaning | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `first` | Page size, counted from the start or from `after`. Default 30, maximum 100. | | `after` | A cursor: return the records after this position | | `last`, `before` | The same, backwards: `last=2` returns the last two records, `before` the records before a cursor. With `first`, `last` takes the last records of that first page. | ## Read page_info Every list response carries `total`, the number of all matching records rather than of this page, and the cursors of the page you received: **Recorded response · REST · 200 OK** ```json { "success": true, "total": 3, "page_info": { "start_cursor": "WyJDb250b3NvIEZyZWlnaHQiLCAiMGEwMDAwMDAtMDAwMC00MDAwLWEwMDAtMDAwMDAwMDAwMDAxIl0=", "end_cursor": "WyJGYWJyaWthbSBSb2JvdGljcyIsICIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==", "has_previous_page": false, "has_next_page": true }, "data": [ { "id": "0a000000-0000-4000-a000-000000000001", "city": "Tacoma", "name": "Contoso Freight" }, { "id": "0a000000-0000-4000-a000-000000000002", "city": "Vancouver", "name": "Fabrikam Robotics" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "account": { "getByCriteria": { "totalCount": 3, "pageInfo": { "hasNextPage": true, "endCursor": "WyJGYWJyaWthbSBSb2JvdGljcyIsICIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==" }, "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Contoso Freight", "city": "Tacoma" } }, { "node": { "id": "0a000000-0000-4000-a000-000000000002", "name": "Fabrikam Robotics", "city": "Vancouver" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ | Field | Meaning | | --------------------------------------------- | ------------------------------------------- | | `page_info.has_next_page` | More records follow: ask again with `after` | | `page_info.end_cursor` | The value to send as `after` | | `page_info.start_cursor`, `has_previous_page` | The same for paging backwards with `before` | ## Loop through every page This loop lists the accounts in Tacoma, Portland and Vancouver, two at a time so that these three accounts of the docs world span two pages. Use up to 100 per page in your own code: **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # 2, so that the three accounts span two pages; send up to 100. PAGE_SIZE=2 cursor="" after=() while :; do # The same filter, sort and page size on every page; only after changes. page=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ -d "filter[city]=Tacoma,Portland,Vancouver" \ -d order-by=name \ -d load-only=id,name,city \ -d "first=$PAGE_SIZE" \ "${after[@]}" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") jq -r '.data[] | "\(.name) (\(.city))"' <<<"$page" [ "$(jq -r '.page_info.has_next_page' <<<"$page")" = true ] || break next=$(jq -r '.page_info.end_cursor' <<<"$page") # An unchanged cursor would ask for the same page again: stop rather than loop forever. [ "$next" != "$cursor" ] || break cursor=$next # --data-urlencode: a raw "+" in the cursor would arrive as a space. after=(--data-urlencode "after=$cursor") done ``` **REST · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); // 2, so that the three accounts span two pages; send up to 100. const PAGE_SIZE = 2; // The same filter, sort and page size on every page. The commas in filter[city] stay raw: a raw // comma separates OR values, while %2C (what URLSearchParams sends) is a literal comma. const query = `filter[city]=Tacoma,Portland,Vancouver&order-by=name&load-only=id,name,city&first=${PAGE_SIZE}`; let cursor: string | null = null; do { // encodeURIComponent: a raw "+" in the cursor would arrive as a space. const after: string = cursor ? `&after=${encodeURIComponent(cursor)}` : ""; const res = await fetch(`${base}/entities/Accounts?${query}${after}`, { headers: { Authorization: `Basic ${auth}` }, }); const page = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${page.name}: ${page.message}`); for (const account of page.data) console.log(`${account.name} (${account.city})`); const next: string | null = page.page_info.has_next_page ? page.page_info.end_cursor : null; // An unchanged cursor would ask for the same page again: stop rather than loop forever. cursor = next === cursor ? null : next; } while (cursor); ``` **REST · Python** ```python import os import sys from urllib.parse import quote import requests 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"]) # 2, so that the three accounts span two pages; send up to 100. PAGE_SIZE = 2 # The same filter, sort and page size on every page. The commas in filter[city] stay raw: a raw # comma separates OR values, while %2C (what params= sends) is a literal comma. query = f"filter[city]=Tacoma,Portland,Vancouver&order-by=name&load-only=id,name,city&first={PAGE_SIZE}" cursor = None while True: # quote(..., safe=""): a raw "+" in the cursor would arrive as a space. after = f"&after={quote(cursor, safe='')}" if cursor else "" response = session.get(f"{BASE}/entities/Accounts?{query}{after}", timeout=30) page = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {page.get('name')}: {page.get('message')}") for account in page["data"]: print(f"{account['name']} ({account['city']})") if not page["page_info"]["has_next_page"]: break # An unchanged cursor would ask for the same page again: stop rather than loop forever. if page["page_info"]["end_cursor"] == cursor: break cursor = page["page_info"]["end_cursor"] ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" # 2, so that the three accounts span two pages; send up to 100. PAGE_SIZE=2 QUERY=$(cat <<'GRAPHQL' query Accounts($first: Int!, $after: String) { entities { account { getByCriteria( first: $first after: $after filter: { city: { eq: ["Tacoma", "Portland", "Vancouver"] } } orderBy: [{ name: Asc }] ) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name city } } } } } } GRAPHQL ) cursor=null while :; do page=$(jq -n --arg query "$QUERY" --argjson first "$PAGE_SIZE" --argjson after "$cursor" \ '{ query: $query, variables: { first: $first, after: $after } }' | curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @-) # GraphQL reports errors in the body, often with HTTP 200. if jq -e '.errors' <<<"$page" >/dev/null; then echo "$page" >&2 exit 1 fi jq -r '.data.entities.account.getByCriteria.edges[].node | "\(.name) (\(.city))"' <<<"$page" [ "$(jq -r '.data.entities.account.getByCriteria.pageInfo.hasNextPage' <<<"$page")" = true ] || break cursor=$(jq '.data.entities.account.getByCriteria.pageInfo.endCursor' <<<"$page") done ``` **GraphQL · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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: check both. 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; } // 2, so that the three accounts span two pages; send up to 100. const PAGE_SIZE = 2; const ACCOUNTS = `query Accounts($first: Int!, $after: String) { entities { account { getByCriteria( first: $first after: $after filter: { city: { eq: ["Tacoma", "Portland", "Vancouver"] } } orderBy: [{ name: Asc }] ) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name city } } } } } }`; let after: string | null = null; do { const data = await gql(ACCOUNTS, { first: PAGE_SIZE, after }); const page = data.entities.account.getByCriteria; for (const edge of page.edges) console.log(`${edge.node.name} (${edge.node.city})`); after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; } while (after); ``` **GraphQL · Python** ```python import json import os import sys import requests 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: check both.""" 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"] # 2, so that the three accounts span two pages; send up to 100. PAGE_SIZE = 2 ACCOUNTS = """ query Accounts($first: Int!, $after: String) { entities { account { getByCriteria( first: $first after: $after filter: { city: { eq: ["Tacoma", "Portland", "Vancouver"] } } orderBy: [{ name: Asc }] ) { totalCount pageInfo { hasNextPage endCursor } edges { node { id name city } } } } } } """ after = None while True: page = gql(ACCOUNTS, {"first": PAGE_SIZE, "after": after})["entities"]["account"]["getByCriteria"] for edge in page["edges"]: print(f"{edge['node']['name']} ({edge['node']['city']})") if not page["pageInfo"]["hasNextPage"]: break after = page["pageInfo"]["endCursor"] ``` > **URL-encode the cursor:** Cursors are base64 and can contain `+`, `/` and `=`. Query values are URL-decoded, and a raw `+` > becomes a space, which breaks the cursor. Encode it as the sample does: `--data-urlencode`, > `encodeURIComponent` or `urllib.parse.quote`. ## GraphQL connections Use a repository's `getByCriteria`: it returns a connection with `totalCount`, `pageInfo` and `edges`, and takes `first` and `after`. `getAll` takes `limit` and `offset` and has no cursor; prefer `getByCriteria` for anything you loop over. Always send `first`, at most 100. ## Legacy limit and offset `limit` and `offset` still work on REST lists, up to 100 per page, but they are deprecated: the response has `total` and `data` and no `page_info`. Use cursors. ## Limits | Limit | Value | What happens above it | | --------------------------- | ----- | ---------------------------------------------------------------------------------- | | Page size (`first`, `last`) | 100 | HTTP 400, code 117 `ERROR_PARAMETER_INVALID`: "Limit must not be greater than 100" | | Default page size | 30 | Used when you send no `first` | | Legacy `limit` / `offset` | 100 | Accepted, but the response has no `page_info`; use cursors | | GraphQL `getByCriteria` | 100 | Send `first` on every call | --- title: Dates and time zones description: Date and date-time fields, the space REST writes between date and time, and the Timezone header that returns date-times in your own zone. canonical: https://developers.coevera.com/concepts/dates-and-time-zones/ section: concepts verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Dates and time zones **Answer:** Date fields are YYYY-MM-DD. Date-times come back in UTC with an offset, with a space between date and time in REST and a T in GraphQL. Send a Timezone header, such as Europe/Bratislava, to get them in that zone. Values you send without an offset are read as UTC, whatever the header says. Two kinds of field hold time. Date fields hold a day; date-time fields hold an instant, which the API writes in UTC unless you ask for your own time zone. ## Date and date-time fields | Kind | Example field | REST | GraphQL | | --------- | ----------------------------------------------------- | ---------------------------------- | ---------------------------------- | | Date | `closing_date` (shown in the app as **Closing date**) | `2026-12-15` | `2026-12-15` | | Date-time | `created`, `modified` | `2026-10-15 09:00:00.584362+00:00` | `2026-10-15T09:00:00.584362+00:00` | - REST writes a **space** between the date and the time; GraphQL writes a `T`. A parser that insists on RFC 3339's `T` fails on REST values: replace the space with `T` before you parse. - Date-times carry microseconds and an offset, `+00:00` unless you send a `Timezone` header. - A date field has no time and no zone; the `Timezone` header does not change it. ## Read date-times in your time zone Send a `Timezone` header and every date-time in the response comes back in that zone, with its offset. The sample reads one opportunity twice, without the header and with `Timezone: Europe/Bratislava`: **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # No Timezone header: created and modified come back in UTC. curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ -d "filter[closing_date]=2026-12-15" \ -d load-only=name,closing_date,created,modified \ -d first=1 \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" echo # The same request with a Timezone header: the same instants, in that zone's offset. curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ -H "Timezone: Europe/Bratislava" \ -d "filter[closing_date]=2026-12-15" \ -d load-only=name,closing_date,created,modified \ -d first=1 \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" echo ``` **REST · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); // The opportunity that closes on 2026-12-15, with its date and its two date-times. const query = "filter[closing_date]=2026-12-15&load-only=name,closing_date,created,modified&first=1"; async function opportunities(headers: Record) { const res = await fetch(`${base}/entities/Opportunities?${query}`, { headers: { Authorization: `Basic ${auth}`, ...headers }, }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${body.message}`); return body.data; } // No Timezone header: created and modified come back in UTC. console.log("UTC:", JSON.stringify(await opportunities({}), null, 2)); // The same request with a Timezone header: the same instants, in that zone's offset. console.log("Europe/Bratislava:", JSON.stringify(await opportunities({ Timezone: "Europe/Bratislava" }), null, 2)); ``` **REST · Python** ```python import json import os import sys import requests 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"]) # The opportunity that closes on 2026-12-15, with its date and its two date-times. QUERY = "filter[closing_date]=2026-12-15&load-only=name,closing_date,created,modified&first=1" def opportunities(headers): response = session.get(f"{BASE}/entities/Opportunities?{QUERY}", headers=headers, timeout=30) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {body.get('message')}") return body["data"] # No Timezone header: created and modified come back in UTC. print("UTC:", json.dumps(opportunities({}), indent=2)) # The same request with a Timezone header: the same instants, in that zone's offset. print("Europe/Bratislava:", json.dumps(opportunities({"Timezone": "Europe/Bratislava"}), indent=2)) ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" # The opportunity, with its date and its two date-times. QUERY='{"query": "{ entities { opportunity { getByCriteria(first: 1, filter: { name: { icontains: [\"data platform rollout\"] } }) { edges { node { name closingDate created modified } } } } } }"}' # No Timezone header: created and modified come back in UTC. curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$QUERY" echo # The same request with a Timezone header: the same instants, in that zone's offset. curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -H "Timezone: Europe/Bratislava" \ -d "$QUERY" echo ``` **GraphQL · TypeScript** ```ts import { Buffer } from "node:buffer"; import { env } from "node:process"; 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"); // The opportunity, with its date and its two date-times. const OPPORTUNITY = `{ entities { opportunity { getByCriteria(first: 1, filter: { name: { icontains: ["data platform rollout"] } }) { edges { node { name closingDate created modified } } } } } }`; async function opportunities(headers: Record) { const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", ...headers }, body: JSON.stringify({ query: OPPORTUNITY }), }); const body = await res.json(); // GraphQL reports errors in the body, often with HTTP 200: check both. if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`); return body.data.entities.opportunity.getByCriteria.edges; } // No Timezone header: created and modified come back in UTC. console.log("UTC:", JSON.stringify(await opportunities({}), null, 2)); // The same request with a Timezone header: the same instants, in that zone's offset. console.log("Europe/Bratislava:", JSON.stringify(await opportunities({ Timezone: "Europe/Bratislava" }), null, 2)); ``` **GraphQL · Python** ```python import json import os import sys import requests ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) AUTH = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) # The opportunity, with its date and its two date-times. OPPORTUNITY = """ { entities { opportunity { getByCriteria(first: 1, filter: { name: { icontains: ["data platform rollout"] } }) { edges { node { name closingDate created modified } } } } } } """ def opportunities(headers): response = requests.post(ENDPOINT, json={"query": OPPORTUNITY}, auth=AUTH, headers=headers, timeout=30) body = response.json() # GraphQL reports errors in the body, often with HTTP 200: check both. if not response.ok or body.get("errors"): sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}") return body["data"]["entities"]["opportunity"]["getByCriteria"]["edges"] # No Timezone header: created and modified come back in UTC. print("UTC:", json.dumps(opportunities({}), indent=2)) # The same request with a Timezone header: the same instants, in that zone's offset. print("Europe/Bratislava:", json.dumps(opportunities({"Timezone": "Europe/Bratislava"}), indent=2)) ``` Without the header, `created` is in UTC: **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwMDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwMDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "modified": "2026-10-15 09:41:21.513848+00:00", "created": "2026-10-15 09:00:00.000000+00:00", "closing_date": "2026-12-15", "name": "Northwind data platform rollout" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opportunity": { "getByCriteria": { "edges": [ { "node": { "name": "Northwind data platform rollout", "closingDate": "2026-12-15", "created": "2026-10-15T09:00:00.000000+00:00", "modified": "2026-10-15T09:41:21.513848+00:00" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ With it, the same instant comes back at `+02:00`, the offset Bratislava had on the day of the recording; `closing_date` is unchanged: **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwMDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwMDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "modified": "2026-10-15 11:41:21.513848+02:00", "created": "2026-10-15 11:00:00.000000+02:00", "closing_date": "2026-12-15", "name": "Northwind data platform rollout" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opportunity": { "getByCriteria": { "edges": [ { "node": { "name": "Northwind data platform rollout", "closingDate": "2026-12-15", "created": "2026-10-15T11:00:00.000000+02:00", "modified": "2026-10-15T11:41:21.513848+02:00" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ The header takes these forms: | Form | Example | Offset on 2026-10-15 | | ----------------------------- | --------------------------------------- | ------------------------------------------- | | IANA zone name | `Europe/Bratislava`, `America/New_York` | `+02:00`, `-04:00`: daylight saving applies | | Windows zone name | `Central Europe Standard Time` | `+02:00` | | `UTC` or `GMT` with an offset | `UTC+2`, `UTC-03:30`, `GMT-5` | `+02:00`, `-03:30`, `-05:00` | | Minutes east of UTC | `60` | `+01:00` | | `UTC` | `UTC` | `+00:00` | A one-word abbreviation such as `CET`, or a name the API does not know, is ignored without an error: the response stays in UTC. GraphQL honours the header too. ## Send date-times - A date-time you send without an offset is read as UTC, even when you send a `Timezone` header. `filter[created]=2026-10-15 05:06:36` with `Timezone: Europe/Bratislava` means `05:06:36` in UTC, not in Bratislava. Send an offset, `2026-10-15T07:06:36+02:00`, or convert to UTC first. - Filters accept a space or a `T` between date and time, and a date alone on a date-time field: `filter[created]=2026-10-15&filter-op[created]=gte`. - Filter a date field with `YYYY-MM-DD`: `filter[closing_date]=2026-12-15`. GraphQL has no filter on `closingDate`. - `created` and `modified` can be set on create, for example when you import records with their original dates; the values you send are stored. A value more than one day after the time of the request, on a create or an update, is ignored: the write succeeds and the field is set as if you had not sent it. To store such a value, add `validation-level` bit 8 ([validation](/concepts/validation/#the-bits)). ## Limits | Limit | Details | | ----------------------------------- | -------------------------------------------------------------------------------- | | Zone abbreviations | `CET`, `EST` and other one-word names are ignored; use an IANA name | | Input without an offset | Read as UTC, whatever the `Timezone` header says | | Separator | REST writes a space, GraphQL a `T` | | Date fields | No zone; the header does not apply | | `created`, `modified` in the future | More than one day ahead is ignored on write, unless `validation-level` has bit 8 | --- title: Batch operations description: Create, update, delete or restore many records in one call. By default one invalid item fails the whole batch; with rollback-method=1 a batch can succeed in part. canonical: https://developers.coevera.com/concepts/batch-operations/ section: concepts verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Batch operations **Answer:** By default, one invalid item fails the whole batch with HTTP 422 and nothing is written; the error does not say which item failed. With rollback-method=1 the valid items are written, the batch answers HTTP 200 with success: false, and data holds one entry per item in input order: the record's ID or an error. A batch writes many records in one request. It is faster than one request per record, and it decides what happens to the valid items when one of them is invalid: read that part before you send your first batch. ## Batch endpoints | Endpoint | Body | Does | | --------------------------------------- | ------------------- | ------------------------------------------------------------- | | `POST /entities/{Entity}/batch-modify` | An array of records | Creates the items without an `id`, updates the items with one | | `POST /entities/{Entity}/batch-delete` | An array of IDs | Soft-deletes the records | | `POST /entities/{Entity}/batch-restore` | An array of IDs | Restores soft-deleted records | In GraphQL, `bulkUpdate` mutations such as `bulkUpdateContact` create and update in one call. Every entity's batch operations are on its reference page, such as [`POST /entities/Contacts/batch-modify`](/reference/rest/contacts/#batch-modify) and [`bulkUpdateContact`](/reference/graphql/contact/#m-bulkUpdateContact). The API sets no batch size limit. Send **up to 100 records per request** for a quick response: a batch takes about 40 ms per record, so 500 records take about 20 s. A larger batch is not refused, but it answers more slowly, and by default one invalid item throws away more work. Deleting a record leaves its relation records active. Deleting contacts or accounts leaves their `ContactAccountRelations` in place, still pointing at the deleted records, and deleting a `CloudObjectRelation` leaves its `CloudObject`, the file. Delete those on their own. ## By default, all or nothing The sample sends three contacts; the third has no owner. Sent without `rollback-method`, the batch fails as a whole: **REST · cURL** ```bash # No --fail-with-body: the 422 is the expected answer here. curl -sS -w '\nHTTP %{http_code}\n' "$COEVERA_BASE/entities/Contacts/batch-modify" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$CONTACTS" ``` **REST · TypeScript** ```ts const strict = await call("/entities/Contacts/batch-modify", contacts); console.log(`Default rollback: HTTP ${strict.status}`, JSON.stringify(strict.body, null, 2)); ``` **REST · Python** ```python strict = session.post(f"{BASE}/entities/Contacts/batch-modify", json=contacts, timeout=60) print(f"Default rollback: HTTP {strict.status_code}", json.dumps(strict.json(), indent=2)) ``` **GraphQL · cURL** ```bash batch ROLLBACK_ALL | gql echo ``` **GraphQL · TypeScript** ```ts const strict = await gql(ADD_CONTACTS, { input: contacts, rollbackMethod: "ROLLBACK_ALL" }); console.log("ROLLBACK_ALL:", JSON.stringify(strict, null, 2)); ``` **GraphQL · Python** ```python strict = gql(ADD_CONTACTS, {"input": contacts, "rollbackMethod": "ROLLBACK_ALL"}) print("ROLLBACK_ALL:", json.dumps(strict, indent=2)) ``` The API answers HTTP 422 with code 40000 `ERROR_ENTITY_VALIDATION` and writes nothing, not even the two valid contacts. The error carries `entity_index: null` and an `entity_id` the server made up for the new record, so it does not tell you which input item failed: **Recorded response · REST · 422 Unprocessable Entity** ```json { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner_id' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit_id' is null or empty.", "entity_id": "0c000000-0000-4000-a000-000000000001", "entity_name": "Contact", "entity_errors": [], "field_errors": [ { "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "unit_id", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "unit_id" }, { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit_id' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "unit_id" } ] }, { "field_id": "10000000-0000-4000-a000-000000000002", "field_name": "owner_id", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000002", "field_name": "owner_id" }, { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner_id' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000002", "field_name": "owner_id" } ] } ], "step_checklist_errors": [], "entity_index": null, "http_status": 422, "success": false } ``` **Recorded response · GraphQL · 200 OK** ```json { "errors": [ { "message": "\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner_id' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit_id' is null or empty.", "locations": [ { "line": 2, "column": 3 } ], "path": [ "bulkUpdateContact" ], "api_error": { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner_id' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit_id' is null or empty.", "entity_id": "0c000000-0000-4000-a000-000000000001", "entity_name": "Contact", "entity_errors": [], "field_errors": [ { "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "unit_id", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "unit_id" }, { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].unit_id' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "unit_id" } ] }, { "field_id": "10000000-0000-4000-a000-000000000002", "field_name": "owner_id", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000002", "field_name": "owner_id" }, { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "Non-null field 'Contact'[0c000000-0000-4000-a000-000000000001].owner_id' is null or empty.", "field_id": "10000000-0000-4000-a000-000000000002", "field_name": "owner_id" } ] } ], "step_checklist_errors": [], "entity_index": null } } ], "data": { "bulkUpdateContact": null } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ GraphQL's default, `rollbackMethod: ROLLBACK_ALL`, behaves the same way, with HTTP 200, the error in `errors` and no result. A `batch-delete` with an ID that does not exist fails the same way: HTTP 404, code 143 `ERROR_NO_ENTITY`, and nothing is deleted. ## Partial failures The same batch sent with `rollback-method=1` writes the two valid contacts and reports the third. The [create an account with contacts](/recipes/create-account-with-contacts/) recipe adds its contacts this way: **REST · cURL** ```bash partial=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Contacts/batch-modify?rollback-method=1" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$CONTACTS") echo "$partial" ``` **REST · TypeScript** ```ts const partial = await call("/entities/Contacts/batch-modify?rollback-method=1", contacts); console.log(`rollback-method=1: HTTP ${partial.status}`, JSON.stringify(partial.body, null, 2)); if (partial.status !== 200) throw new Error(`Expected HTTP 200, got ${partial.status}`); ``` **REST · Python** ```python partial = session.post( f"{BASE}/entities/Contacts/batch-modify", params={"rollback-method": 1}, json=contacts, timeout=60, ) print(f"rollback-method=1: HTTP {partial.status_code}", json.dumps(partial.json(), indent=2)) if partial.status_code != 200: sys.exit(f"Expected HTTP 200, got {partial.status_code}") ``` **GraphQL · cURL** ```bash partial=$(batch ROLLBACK_FAILED | gql) echo "$partial" if jq -e '.errors' <<<"$partial" >/dev/null; then echo "Unexpected GraphQL errors" >&2 exit 1 fi ``` **GraphQL · TypeScript** ```ts const partial = await gql(ADD_CONTACTS, { input: contacts, rollbackMethod: "ROLLBACK_FAILED" }); console.log("ROLLBACK_FAILED:", JSON.stringify(partial, null, 2)); if (partial.errors) throw new Error(`Unexpected GraphQL errors: ${JSON.stringify(partial.errors)}`); ``` **GraphQL · Python** ```python partial = gql(ADD_CONTACTS, {"input": contacts, "rollbackMethod": "ROLLBACK_FAILED"}) print("ROLLBACK_FAILED:", json.dumps(partial, indent=2)) if partial.get("errors"): sys.exit(f"Unexpected GraphQL errors: {json.dumps(partial['errors'])}") ``` **Recorded response · REST · 200 OK** ```json { "success": false, "data": [ "0c000000-0000-4000-a000-000000000002", "0c000000-0000-4000-a000-000000000003", { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].owner' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].unit' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].owner_id' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].unit_id' is null or empty." } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "bulkUpdateContact": { "result": { "created": [ { "index": 0, "id": "0c000000-0000-4000-a000-000000000002" }, { "index": 1, "id": "0c000000-0000-4000-a000-000000000003" } ], "errors": [ { "index": 2, "code": 40000, "message": "\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].owner' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].unit' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].owner_id' is null or empty.\nNon-null field 'Contact'[0c000000-0000-4000-a000-000000000004].unit_id' is null or empty." } ] } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ### Read per-item results `data` holds one entry per input item, in input order: | Item | Entry in `data` | Meaning | | ---- | ---------------------------------------------------------------------- | ----------------------------------------- | | 0 | `"0c000000-…-000000000002"` | Created; the record's ID | | 1 | `"0c000000-…-000000000003"` | Created | | 2 | `{ "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "…" }` | Failed; nothing was written for this item | An error entry has only `code`, `name` and `message`, without `field_errors`. In GraphQL, `rollbackMethod: ROLLBACK_FAILED` returns `result.created` and `result.errors`, each entry with the `index` of its input item. > **Checking only the HTTP status loses data:** With `rollback-method=1`, a batch with failed items still answers HTTP 200. An integration that > treats every 200 as success drops the failed items without noticing. Always walk `data`, and > treat `success: false` as "some items failed". ## rollback-method | Value | GraphQL | Effect | | ------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` (default) | `ROLLBACK_ALL` | One invalid item fails the whole batch: HTTP 422 (404 for an unknown ID in `batch-delete`). Nothing is written, and the error does not say which item failed. | | `1` | `ROLLBACK_FAILED` | HTTP 200 with `success: false` when an item failed. The valid items are written; each failed item has its error at its position in `data`. | `rollback-method` applies to `batch-modify`, `batch-delete` and `batch-restore`. `validation-level` applies to `batch-modify` only: on `batch-delete` and `batch-restore` it is refused with HTTP 400, code 118 `ERROR_PARAMETER_UNKNOWN`. See [validation](/concepts/validation/). ## Check every item in code Walk `data` after every batch sent with `rollback-method=1`, and report or retry the failed items: **REST · cURL** ```bash # One line per item, in input order: the new ID, or the error in its place. jq -r '.data | to_entries[] | if (.value | type) == "string" then "\(.key) created \(.value)" else "\(.key) FAILED \(.value.code) \(.value.name)" end' <<<"$partial" ``` **REST · TypeScript** ```ts type ItemError = { code: number; name: string; message: string }; const results = partial.body.data.map((item: string | ItemError, index: number) => typeof item === "string" ? { index, ok: true, id: item } : { index, ok: false, code: item.code, message: item.message }, ); const failed = results.filter((r: { ok: boolean }) => !r.ok); if (failed.length) console.warn(`${failed.length} of ${results.length} items failed`, failed); ``` **REST · Python** ```python results = [ {"index": index, "ok": True, "id": item} if isinstance(item, str) else {"index": index, "ok": False, "code": item["code"], "message": item["message"]} for index, item in enumerate(partial.json()["data"]) ] failed = [result for result in results if not result["ok"]] if failed: print(f"{len(failed)} of {len(results)} items failed", failed, file=sys.stderr) ``` **GraphQL · cURL** ```bash jq -r '.data.bulkUpdateContact.result | (.created[] | "\(.index) created \(.id)"), (.errors[] | "\(.index) FAILED \(.code)")' <<<"$partial" ``` **GraphQL · TypeScript** ```ts const { created, errors } = partial.data.bulkUpdateContact.result; for (const item of created) console.log(`${item.index} created ${item.id}`); for (const item of errors) console.warn(`${item.index} FAILED ${item.code}: ${item.message.trim()}`); ``` **GraphQL · Python** ```python result = partial["data"]["bulkUpdateContact"]["result"] for item in result["created"]: print(f"{item['index']} created {item['id']}") for item in result["errors"]: print(f"{item['index']} FAILED {item['code']}: {item['message'].strip()}", file=sys.stderr) ``` ## Limits | Limit | Value | Details | | ------------------ | ------------------------------ | ------------------------------------------------------------------------------------------- | | Batch size | None set | Up to 100 per request recommended; 500 take about 20 s | | Which item failed | Not reported by default | Use `rollback-method=1` to get per-item results | | `validation-level` | `batch-modify` only | Refused on `batch-delete` and `batch-restore` with HTTP 400, code 118 | | Relation records | Not deleted with their records | Delete `ContactAccountRelations` and the `CloudObject` of a removed attachment on their own | | Request body size | None documented | A batch of 500 records (42 kB) is accepted | --- title: Validation description: Relax server-side validation on a write with the validation-level bit flags. Which bits are public, what each does, and how to add them up. canonical: https://developers.coevera.com/concepts/validation/ section: concepts verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Validation **Answer:** validation-level relaxes validation on create, update and batch-modify. It takes a bit mask, member names or a comma list: 2 skips unchanged fields, 16 allows links to deleted records, 32 lets an update empty a required field, 512 allows duplicates in a unique field. Use 2 to update records that are already invalid. Every write is validated: required fields, e-mail formats, links to other records, unique values. `validation-level` turns some of those checks off for one request. The usual reason to use it is a sync that writes back records which were already invalid when it read them. ## How it works `validation-level` is a query parameter of `POST /entities/{Entity}` (create), `PATCH /entities/{Entity}/{id}` (update) and `POST /entities/{Entity}/batch-modify`. On `batch-delete` and `batch-restore` it is refused with HTTP 400, code 118. In GraphQL, the create, update, bulk and `convert…` mutations take `validationLevel` as a list of names: `validationLevel: [SKIP_UNCHANGED_FIELDS]`. Leave the argument out for full validation: an explicit `validationLevel: null` on `bulkUpdateAccount` fails with code 1000 `ERROR_UNKNOWN`, even for a valid update. The value is a bit mask. Send it as a number, by name, or as a comma list of either; a list is combined with OR, so a repeated value does no harm: | Form | Example | Means | | ------- | --------------------------------------------------------------------- | -------- | | Number | `validation-level=18` | 2 + 16 | | Names | `validation-level=SKIP_UNCHANGED_FIELDS,SKIP_RELATIONSHIP_VALIDATION` | The same | | Numbers | `validation-level=2,16` | The same | Add the bits up to combine them, and take a number apart the same way: `416` is 256 + 128 + 32, not 4 and 16 (that is `20`). ## The bits | Bit | Name | What it does | | --- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | `FULL` | Every check; the default | | 2 | `SKIP_UNCHANGED_FIELDS` | A `batch-modify` update goes through on a record that already holds an invalid value it does not change, whether it re-sends that value or leaves it out. On create, it also lets a contact through without `first_name` or `last_name`. | | 4 | `SKIP_USER_DEFINED_VALIDATIONS` | Skips the rules an admin sets on forms | | 8 | `SKIP_READONLY_VALIDATION` | Keeps a `created` or `modified` more than one day in the future, which a write otherwise ignores ([dates](/concepts/dates-and-time-zones/#date-and-date-time-fields)) | | 16 | `SKIP_RELATIONSHIP_VALIDATION` | A record may link to a deleted record, such as a `parent_account_id` of a deleted account | | 32 | `SKIP_FIELD_VALUE_VALIDATION` | An update may set a required field to empty, such as `"name": ""` by PATCH | | 64 | `SKIP_INVALID_RECALCULATIONS` | Accepted, and part of `SKIP_ALL` | | 128 | `SKIP_STEP_CHECKLIST_VALIDATION` | Skips the checks of step checklists | | 254 | `SKIP_ALL` | Bits 2 to 128 together. Only this full set lets through an invalid e-mail format or a dropdown value from another field's options. | | 512 | `SKIP_UNIQUE_VALIDATION` | Allows a duplicate in a field that must be unique. Not part of `SKIP_ALL`: add it, `766`. | - No bit lets a create through without its name: an account without `name` is refused with HTTP 422, even with `SKIP_ALL`. - A contact needs `first_name` or `last_name`, although the published spec marks only `owner_id` as required: without both, the create answers HTTP 422, code 119. - Other creates need more than the published spec marks as required, and answer HTTP 422 without it: an opportunity needs `account_relations` and a `value` with `currency_id` ([create an opportunity](/recipes/create-opportunity/)), a product needs `unit_symbol`, and its `sku` must be unique, or the create answers HTTP 400 ([products and line items](/recipes/products-and-line-items/)), and a lead needs `owner_id` or `unit_id` ([lead from a web form](/recipes/lead-from-web-form/)). - Send only the bits in this table. A value that carries either of two internal bits is refused with HTTP 400, code 117 `ERROR_PARAMETER_INVALID`, and nothing is written. That holds on create, update and `batch-modify`, when the bit is added to public bits or sent in a comma list, and for `-1`, which sets every bit. Other bits that name no member, such as `1` or `1024`, are accepted without an error. ## Update records that are already invalid A PATCH ignores the fields it does not change, even when you send them with their current value. A `batch-modify` update does not: it validates the whole record, fields you do not send included. A `batch-modify` therefore fails on a record that holds an invalid value, whether the update sends that value unchanged or leaves it out. The sample creates such a record, an account whose `email1` is `not-an-email`. It stands in for old data and uses `SKIP_ALL` (254) to get past the e-mail check; do not do that in your own code: **REST · cURL** ```bash # Stands in for a record that is already invalid, as older data often is: validation-level=254 # (SKIP_ALL, every skip bit from 2 to 128) lets the invalid e-mail address in. Do not do this # in your own code. account=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Accounts?validation-level=254" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- <= 300) throw new Error(`HTTP ${created.status} ${created.body.name}: ${created.body.message}`); const accountId: string = created.body.data.id; ``` **REST · Python** ```python # Stands in for a record that is already invalid, as older data often is: validation-level=254 # (SKIP_ALL, every skip bit from 2 to 128) lets the invalid e-mail address in. Do not do this # in your own code. created = session.post( f"{BASE}/entities/Accounts", params={"validation-level": 254}, json={"name": "Northwind Analytics", "owner_id": owner_id, "email1": "not-an-email"}, timeout=60, ) if not created.ok: sys.exit(f"HTTP {created.status_code}: {created.text}") account_id = created.json()["data"]["id"] ``` **GraphQL · cURL** ```bash # Stands in for a record that is already invalid, as older data often is: SKIP_ALL lets the # invalid e-mail address in. Do not do this in your own code. ACCOUNT_ID=$(jq -n --arg owner "$OWNER_ID" '{ query: "mutation Existing($input: CreateAccountInput!) { createAccount(input: $input, validationLevel: [SKIP_ALL]) { account { id } } }", variables: { input: { name: "Northwind Analytics", ownerId: $owner, email1: "not-an-email" } } }' | gql | jq -er '.data.createAccount.account.id') ``` **GraphQL · TypeScript** ```ts // Stands in for a record that is already invalid, as older data often is: SKIP_ALL lets the // invalid e-mail address in. Do not do this in your own code. const created = await gql( `mutation Existing($input: CreateAccountInput!) { createAccount(input: $input, validationLevel: [SKIP_ALL]) { account { id } } }`, { input: { name: "Northwind Analytics", ownerId, email1: "not-an-email" } }, ); if (created.errors) throw new Error(`Unexpected GraphQL errors: ${JSON.stringify(created.errors)}`); const accountId: string = created.data.createAccount.account.id; ``` **GraphQL · Python** ```python # Stands in for a record that is already invalid, as older data often is: SKIP_ALL lets the # invalid e-mail address in. Do not do this in your own code. created = gql( """ mutation Existing($input: CreateAccountInput!) { createAccount(input: $input, validationLevel: [SKIP_ALL]) { account { id } } } """, {"input": {"name": "Northwind Analytics", "ownerId": owner_id, "email1": "not-an-email"}}, ) if created.get("errors"): sys.exit(f"Unexpected GraphQL errors: {json.dumps(created['errors'])}") account_id = created["data"]["createAccount"]["account"]["id"] ``` A `batch-modify` that changes only `city`, and sends the unchanged `email1` with it, is refused: **REST · cURL** ```bash # The sync sends the whole record back with one change, the city. The unchanged, invalid # e-mail address fails the update: HTTP 422, and nothing is written. No --fail-with-body: # the 422 is the expected answer here. UPDATE="[{ \"id\": \"$ACCOUNT_ID\", \"email1\": \"not-an-email\", \"city\": \"Seattle\" }]" curl -sS -w '\nHTTP %{http_code}\n' "$COEVERA_BASE/entities/Accounts/batch-modify" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$UPDATE" ``` **REST · TypeScript** ```ts // The sync sends the whole record back with one change, the city. The unchanged, invalid // e-mail address fails the update: HTTP 422, and nothing is written. const update = [{ id: accountId, email1: "not-an-email", city: "Seattle" }]; const strict = await call("/entities/Accounts/batch-modify", update); console.log(`Full validation: HTTP ${strict.status}`, JSON.stringify(strict.body, null, 2)); ``` **REST · Python** ```python # The sync sends the whole record back with one change, the city. The unchanged, invalid # e-mail address fails the update: HTTP 422, and nothing is written. update = [{"id": account_id, "email1": "not-an-email", "city": "Seattle"}] strict = session.post(f"{BASE}/entities/Accounts/batch-modify", json=update, timeout=60) print(f"Full validation: HTTP {strict.status_code}", json.dumps(strict.json(), indent=2)) ``` **GraphQL · cURL** ```bash # sync [LEVEL]: the update request, the whole record with one change, the city. LEVEL is a JSON # list of validation levels; without it, full validation. Leave validationLevel out rather than # sending null: an explicit null is answered with an unexpected error (code 1000). sync() { jq -n --arg id "$ACCOUNT_ID" --argjson level "${1:-null}" '{ query: "mutation Sync($input: [CreateOrUpdateAccountInput!]!, $validationLevel: [ValidationLevel!]) { bulkUpdateAccount(input: $input, validationLevel: $validationLevel) { result { updated errors { index code message } } } }", variables: ({ input: [{ id: $id, email1: "not-an-email", city: "Seattle" }] } + if $level then { validationLevel: $level } else {} end) }' } # The unchanged, invalid e-mail address fails the update: HTTP 200 with errors and no result, # and nothing is written. sync | gql echo ``` **GraphQL · TypeScript** ```ts // The sync sends the whole record back with one change, the city. The unchanged, invalid // e-mail address fails the update: HTTP 200 with errors and no result, and nothing is written. const UPDATE = `mutation Sync($input: [CreateOrUpdateAccountInput!]!, $validationLevel: [ValidationLevel!]) { bulkUpdateAccount(input: $input, validationLevel: $validationLevel) { result { updated errors { index code message } } } }`; const update = [{ id: accountId, email1: "not-an-email", city: "Seattle" }]; const strict = await gql(UPDATE, { input: update }); console.log("Full validation:", JSON.stringify(strict, null, 2)); ``` **GraphQL · Python** ```python # The sync sends the whole record back with one change, the city. The unchanged, invalid # e-mail address fails the update: HTTP 200 with errors and no result, and nothing is written. UPDATE = """ mutation Sync($input: [CreateOrUpdateAccountInput!]!, $validationLevel: [ValidationLevel!]) { bulkUpdateAccount(input: $input, validationLevel: $validationLevel) { result { updated errors { index code message } } } } """ update = [{"id": account_id, "email1": "not-an-email", "city": "Seattle"}] strict = gql(UPDATE, {"input": update}) print("Full validation:", json.dumps(strict, indent=2)) ``` **Recorded response · REST · 422 Unprocessable Entity** ```json { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nInvalid email in field email1: \"not-an-email\"", "entity_id": "0a000000-0000-4000-a000-000000000001", "entity_name": "Account", "entity_errors": [ { "code": 117, "name": "ERROR_PARAMETER_INVALID", "message": "Invalid email in field email1: \"not-an-email\"" } ], "field_errors": [], "step_checklist_errors": [], "entity_index": null, "http_status": 422, "success": false } ``` **Recorded response · GraphQL · 200 OK** ```json { "errors": [ { "message": "\nInvalid email in field email1: \"not-an-email\"", "locations": [ { "line": 2, "column": 3 } ], "path": [ "bulkUpdateAccount" ], "api_error": { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nInvalid email in field email1: \"not-an-email\"", "entity_id": "0a000000-0000-4000-a000-000000000001", "entity_name": "Account", "entity_errors": [ { "code": 117, "name": "ERROR_PARAMETER_INVALID", "message": "Invalid email in field email1: \"not-an-email\"" } ], "field_errors": [], "step_checklist_errors": [], "entity_index": null } } ], "data": { "bulkUpdateAccount": null } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ The same update with `validation-level=2` goes through. The unchanged e-mail is not checked again, and a changed field is still validated as usual: **REST · cURL** ```bash # The same update with validation-level=2 (SKIP_UNCHANGED_FIELDS): only the city is validated. curl -sS --fail-with-body "$COEVERA_BASE/entities/Accounts/batch-modify?validation-level=2" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$UPDATE" echo ``` **REST · TypeScript** ```ts // The same update with validation-level=2 (SKIP_UNCHANGED_FIELDS): only the city is validated. const relaxed = await call("/entities/Accounts/batch-modify?validation-level=2", update); console.log(`validation-level=2: HTTP ${relaxed.status}`, JSON.stringify(relaxed.body, null, 2)); if (relaxed.status !== 200) throw new Error(`Expected HTTP 200, got ${relaxed.status}`); ``` **REST · Python** ```python # The same update with validation-level=2 (SKIP_UNCHANGED_FIELDS): only the city is validated. relaxed = session.post( f"{BASE}/entities/Accounts/batch-modify", params={"validation-level": 2}, json=update, timeout=60, ) print(f"validation-level=2: HTTP {relaxed.status_code}", json.dumps(relaxed.json(), indent=2)) if relaxed.status_code != 200: sys.exit(f"Expected HTTP 200, got {relaxed.status_code}") ``` **GraphQL · cURL** ```bash # The same update with SKIP_UNCHANGED_FIELDS: only the city is validated. relaxed=$(sync '["SKIP_UNCHANGED_FIELDS"]' | gql) echo "$relaxed" if jq -e '.errors' <<<"$relaxed" >/dev/null; then echo "Unexpected GraphQL errors" >&2 exit 1 fi ``` **GraphQL · TypeScript** ```ts // The same update with SKIP_UNCHANGED_FIELDS: only the city is validated. const relaxed = await gql(UPDATE, { input: update, validationLevel: ["SKIP_UNCHANGED_FIELDS"] }); console.log("SKIP_UNCHANGED_FIELDS:", JSON.stringify(relaxed, null, 2)); if (relaxed.errors) throw new Error(`Unexpected GraphQL errors: ${JSON.stringify(relaxed.errors)}`); ``` **GraphQL · Python** ```python # The same update with SKIP_UNCHANGED_FIELDS: only the city is validated. relaxed = gql(UPDATE, {"input": update, "validationLevel": ["SKIP_UNCHANGED_FIELDS"]}) print("SKIP_UNCHANGED_FIELDS:", json.dumps(relaxed, indent=2)) if relaxed.get("errors"): sys.exit(f"Unexpected GraphQL errors: {json.dumps(relaxed['errors'])}") ``` **Recorded response · REST · 200 OK** ```json { "success": true, "data": [ "0a000000-0000-4000-a000-000000000001" ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "bulkUpdateAccount": { "result": { "updated": [ "0a000000-0000-4000-a000-000000000001" ], "errors": [] } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ `2` is the narrow answer for this case; `SKIP_ALL` turns off far more than you need. ## Limits | Limit | Details | | ---------------- | ----------------------------------------------------------------------------------------------------- | | Where it applies | Create, update and `batch-modify`; refused on `batch-delete` and `batch-restore` (HTTP 400, code 118) | | Never skipped | A missing name on create | | Refused values | Two internal bits in any form, `-1` included: HTTP 400, code 117 `ERROR_PARAMETER_INVALID` | --- title: Errors description: Every REST error has the same JSON shape. Branch on the numeric code, not on the message or the HTTP status alone; a missing permission answers 400, not 403. canonical: https://developers.coevera.com/concepts/errors/ section: concepts verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Errors **Answer:** REST errors come back as { success: false, http_status, code, name, message }. code and name identify the error; message explains it to a person. Validation errors, HTTP 422 with code 40000, add the record and the fields that failed. A missing permission answers 400 with code 50, never 403, so branch on code rather than on the status alone. When a request fails, the status line says roughly what went wrong and the body says exactly what. Read the body. ## The error envelope Asking for more than 100 records per page is refused with HTTP 400: **Recorded response · REST · 400 Bad Request** ```json { "code": 117, "name": "ERROR_PARAMETER_INVALID", "message": "Limit must not be greater than 100", "http_status": 400, "success": false } ``` **Recorded response · GraphQL · 200 OK** ```json { "errors": [ { "message": "On attribute id: Invalid attribute value \"not-a-uuid\"", "locations": [ { "line": 1, "column": 24 } ], "path": [ "entities", "account", "getById" ], "api_error": { "code": 117, "name": "ERROR_PARAMETER_INVALID", "message": "On attribute id: Invalid attribute value \"not-a-uuid\"" } } ], "data": { "entities": { "account": { "getById": null } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ | Field | Holds | | ------------- | --------------------------------------------------------------------------------------------- | | `success` | `false` | | `http_status` | The HTTP status, repeated | | `code` | The error's number, such as `117` | | `name` | The error's name, such as `ERROR_PARAMETER_INVALID` | | `message` | A sentence for people. Validation messages start with a newline: trim it before you show one. | Permission errors also carry `perm`, the permission that is missing. Grant the permission as [permissions and scopes](/get-started/permissions-and-scopes/#what-a-missing-permission-answers) describes. ### Validation errors HTTP 422 with code 40000 `ERROR_ENTITY_VALIDATION` adds what failed: `entity_name`, `entity_id` and `entity_index`, then `entity_errors` for the record as a whole and `field_errors` for single fields (each with code 40002 and its own `errors`), and `step_checklist_errors`. Errors of related records nest the same way. Here an account is created with an owner that does not exist: **Recorded response · REST · 422 Unprocessable Entity** ```json { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nRelated Client with id 00000000-0000-4000-a000-000000000001 doesn't exists.", "entity_id": "0a000000-0000-4000-a000-000000000001", "entity_name": "Account", "entity_errors": [], "field_errors": [ { "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "owner_id", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 120, "name": "ERROR_RELATIONS_DOESNT_EXIST", "message": "Related Client with id 00000000-0000-4000-a000-000000000001 doesn't exists.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "owner_id" } ] } ], "step_checklist_errors": [], "entity_index": null, "http_status": 422, "success": false } ``` **Recorded response · GraphQL · 200 OK** ```json { "errors": [ { "message": "\nRelated Client with id 00000000-0000-4000-a000-000000000001 doesn't exists.", "locations": [ { "line": 2, "column": 3 } ], "path": [ "createAccount" ], "api_error": { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\nRelated Client with id 00000000-0000-4000-a000-000000000001 doesn't exists.", "entity_id": "0a000000-0000-4000-a000-000000000001", "entity_name": "Account", "entity_errors": [], "field_errors": [ { "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "owner_id", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 120, "name": "ERROR_RELATIONS_DOESNT_EXIST", "message": "Related Client with id 00000000-0000-4000-a000-000000000001 doesn't exists.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "owner_id" } ] } ], "step_checklist_errors": [], "entity_index": null } } ], "data": { "createAccount": null } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ In a batch sent with the default rollback, `entity_index` is `null`: the error does not say which item failed ([batch operations](/concepts/batch-operations/#by-default-all-or-nothing)). ## HTTP statuses | HTTP | When | Typical codes | | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | 200 | Success, including a batch with failed items sent with `rollback-method=1` | See [batch operations](/concepts/batch-operations/) | | 201 | A record was created | | | 202 | A write was queued for later with the `delay` parameter | | | 400 | A bad or unknown parameter, input the API cannot store, a missing permission, conflicting data | `117`, `118`, `50`, `52`, `2002` | | 401 | Authentication failed: see [authentication](/get-started/authentication/) | `ERROR_UNAUTHORIZED` | | 404 | No such record, or no such entity. By default, a `batch-delete` with an unknown ID gets 404 for the whole call. | `143`, `2006` | | 422 | Validation failed, or a required value is missing. By default, a batch with a failed item gets 422 for the whole call, and nothing is written. | `40000`, `119` | | 429 | Too many requests: wait for `Retry-After`, see [rate limits](/concepts/rate-limits-and-retries/) | `182` | | 500 | Unexpected server error | `1000` | A method a path does not have, such as `PATCH /entities/{id}` without the entity name, answers HTTP 405 with a body of another shape, `{ "status": 405, "message": "…" }`. > **The REST API never answers 403 or 409:** A missing permission is HTTP 400 with code 50 `ERROR_NOT_ENOUGH_PERMISSIONS`, and a revision or > data conflict is HTTP 400 too, code 2002 `ERROR_CONFLICTING_DATA`. Check `code`, not just the > status. ## GraphQL errors GraphQL answers HTTP 200 for a request it could run, with the failures in `errors`. When the API refused part of it, `errors[].api_error` carries the same body as a REST error, without `success` and `http_status`: code 117 for an ID that is not a UUID, and code 40000 with `field_errors` for a mutation that fails validation. Pick GraphQL in the two responses above to see them. A query that does not match the schema, such as a field name with a typo, answers HTTP 400 with only `errors[].message`, and so does an operation over the [GraphQL operation limits](/concepts/rate-limits-and-retries/#graphql-operation-size). Reading a record that does not exist with `getById` returns `null`, not an error. ## Handle errors in code Raise an error that carries `code`, and decide on it: retry what can be retried, report the rest. This helper, used on every request, also waits and retries on HTTP 429: **REST · cURL** ```bash # call METHOD PATH [BODY]: one REST request; prints the body. On HTTP 429 it waits as long as # Retry-After says (5 seconds when the header is missing) and tries again, three attempts in # all. On any other error it prints "HTTP , code : " and the # body, sets ERROR_CODE to the code and returns 1. call() { local attempt headers response status delay local args=(-X "$1" "$COEVERA_BASE$2" -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") if [ $# -ge 3 ]; then args+=(-H "Content-Type: application/json" -d "$3"); fi headers=$(mktemp) for attempt in 1 2 3; do response=$(curl -sS -D "$headers" -w '\n%{http_code}' "${args[@]}") status=${response##*$'\n'} response=${response%$'\n'*} if [ "$status" = 429 ] && [ "$attempt" -lt 3 ]; then delay=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9][0-9]*\).*/\1/p' "$headers") sleep "${delay:-5}" continue fi rm -f "$headers" if [ "$status" -ge 400 ]; then # The message is for people; branch on the numeric code. jq -r --arg status "$status" \ '"HTTP \($status), code \(.code) \(.name): \(.message | sub("^\\s+"; "") | sub("\\s+$"; ""))"' <<<"$response" echo "$response" ERROR_CODE=$(jq -r '.code' <<<"$response") return 1 fi echo "$response" return 0 done } ``` **REST · TypeScript** ```ts // An error the API answered: the HTTP status, and the code, name and message of its body. class ApiError extends Error { constructor( readonly status: number, readonly code: number, readonly errorName: string, message: string, readonly body: unknown, ) { super(`HTTP ${status}, code ${code} ${errorName}: ${message}`); } } const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); // call: one REST request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the // header is missing) and tries again, three attempts in all. Any other error is an ApiError. async function call(path: string, init: { method?: string; body?: unknown } = {}) { for (let attempt = 1; ; attempt++) { 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), }); if (res.status === 429 && attempt < 3) { await sleep(Number(res.headers.get("Retry-After")) || 5); continue; } const body = await res.json(); // The message is for people; branch on the numeric code. if (!res.ok) throw new ApiError(res.status, body.code, body.name, String(body.message).trim(), body); return body; } } ``` **REST · Python** ```python class ApiError(Exception): """An error the API answered: the HTTP status, and the code, name and message of its body.""" def __init__(self, status, body): self.status, self.body = status, body self.code, self.name = body.get("code"), body.get("name") message = str(body.get("message")).strip() super().__init__(f"HTTP {status}, code {self.code} {self.name}: {message}") def call(method, path, **kwargs): """One REST request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the header is missing) and tries again, three attempts in all. Any other error is an ApiError.""" for attempt in range(1, 4): response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs) if response.status_code == 429 and attempt < 3: time.sleep(float(response.headers.get("Retry-After") or 5)) continue body = response.json() # The message is for people; branch on the numeric code. if not response.ok: raise ApiError(response.status_code, body) return body ``` **GraphQL · cURL** ```bash # call REQUEST: POST one GraphQL request (a JSON body); prints the data. On HTTP 429 it waits as # long as Retry-After says (5 seconds when the header is missing) and tries again, three # attempts in all. On any error it prints "HTTP , code : " and # the error, sets ERROR_CODE to the api_error code (null when there is none) and returns 1. call() { local attempt headers response status delay headers=$(mktemp) for attempt in 1 2 3; do response=$(curl -sS -D "$headers" -w '\n%{http_code}' "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$1") status=${response##*$'\n'} response=${response%$'\n'*} if [ "$status" = 429 ] && [ "$attempt" -lt 3 ]; then delay=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9][0-9]*\).*/\1/p' "$headers") sleep "${delay:-5}" continue fi rm -f "$headers" if [ "$status" != 200 ] || jq -e '.errors' <<<"$response" >/dev/null; then # The message is for people; branch on the numeric code. jq -r --arg status "$status" '.errors[0] | "HTTP \($status), code \(.api_error.code) \(.api_error.name // ""): \(.message | sub("^\\s+"; "") | sub("\\s+$"; ""))"' <<<"$response" jq '.errors[0]' <<<"$response" ERROR_CODE=$(jq -r '.errors[0].api_error.code' <<<"$response") return 1 fi jq '.data' <<<"$response" return 0 done } ``` **GraphQL · TypeScript** ```ts // An error the API answered: the HTTP status, and the code and name of api_error when the // error has one (a document the schema refuses has none). class ApiError extends Error { constructor( readonly status: number, readonly code: number | undefined, readonly errorName: string | undefined, message: string, readonly error: unknown, ) { super(`HTTP ${status}, code ${code ?? "none"} ${errorName ?? ""}: ${message}`); } } const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); // call: one GraphQL request. On HTTP 429 it waits as long as Retry-After says (5 seconds when // the header is missing) and tries again, three attempts in all. Any error is an ApiError. async function call(query: string, variables: object = {}) { for (let attempt = 1; ; attempt++) { const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }); if (res.status === 429 && attempt < 3) { await sleep(Number(res.headers.get("Retry-After")) || 5); continue; } const body = await res.json(); const error = body.errors?.[0]; // The message is for people; branch on the numeric code. if (!res.ok || error) { throw new ApiError(res.status, error?.api_error?.code, error?.api_error?.name, String(error?.message).trim(), error); } return body.data; } } ``` **GraphQL · Python** ```python class ApiError(Exception): """An error the API answered: the HTTP status, and the code and name of api_error when the error has one (a document the schema refuses has none).""" def __init__(self, status, code, name, message, error): super().__init__(f"HTTP {status}, code {code} {name}: {message}") self.status, self.code, self.name, self.error = status, code, name, error def call(query, variables=None): """One GraphQL request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the header is missing) and tries again, three attempts in all. Any error is an ApiError.""" for attempt in range(1, 4): response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30) if response.status_code == 429 and attempt < 3: time.sleep(float(response.headers.get("Retry-After") or 5)) continue body = response.json() error = (body.get("errors") or [None])[0] # The message is for people; branch on the numeric code. if not response.ok or error: api_error = (error or {}).get("api_error") or {} message = str((error or {}).get("message")).strip() raise ApiError(response.status_code, api_error.get("code"), api_error.get("name"), message, error) return body["data"] ``` Then decide on the code. A validation error is not worth retrying: report the field that failed. **REST · cURL** ```bash # An owner that does not exist: HTTP 422, code 40000. field_errors names the field that failed. # Nothing is written. ACCOUNT='{ "name": "Northwind Analytics", "owner_id": "00000000-0000-4000-a000-000000000000" }' if ! call POST "/entities/Accounts" "$ACCOUNT" && [ "$ERROR_CODE" != 40000 ]; then exit 1 fi ``` **REST · TypeScript** ```ts // An owner that does not exist: HTTP 422, code 40000. field_errors names the field that failed. // Nothing is written. try { await call("/entities/Accounts", { method: "POST", body: { name: "Northwind Analytics", owner_id: "00000000-0000-4000-a000-000000000000" }, }); } catch (err) { if (!(err instanceof ApiError) || err.code !== 40000) throw err; console.log(err.message, JSON.stringify(err.body, null, 2)); } ``` **REST · Python** ```python # An owner that does not exist: HTTP 422, code 40000. field_errors names the field that failed. # Nothing is written. try: call( "POST", "/entities/Accounts", json={"name": "Northwind Analytics", "owner_id": "00000000-0000-4000-a000-000000000000"}, ) except ApiError as err: if err.code != 40000: raise print(err, json.dumps(err.body, indent=2)) ``` **GraphQL · cURL** ```bash # An owner that does not exist: HTTP 200 with errors, api_error code 40000. Its field_errors # name the field that failed. Nothing is written. CREATE=$(jq -n '{ query: "mutation Create($input: CreateAccountInput!) { createAccount(input: $input) { account { id } } }", variables: { input: { name: "Northwind Analytics", ownerId: "00000000-0000-4000-a000-000000000000" } } }') if ! call "$CREATE" && [ "$ERROR_CODE" != 40000 ]; then exit 1 fi ``` **GraphQL · TypeScript** ```ts // An owner that does not exist: HTTP 200 with errors, api_error code 40000. Its field_errors // name the field that failed. Nothing is written. try { await call( `mutation Create($input: CreateAccountInput!) { createAccount(input: $input) { account { id } } }`, { input: { name: "Northwind Analytics", ownerId: "00000000-0000-4000-a000-000000000000" } }, ); } catch (err) { if (!(err instanceof ApiError) || err.code !== 40000) throw err; console.log(err.message, JSON.stringify(err.error, null, 2)); } ``` **GraphQL · Python** ```python # An owner that does not exist: HTTP 200 with errors, api_error code 40000. Its field_errors # name the field that failed. Nothing is written. try: call( """ mutation Create($input: CreateAccountInput!) { createAccount(input: $input) { account { id } } } """, {"input": {"name": "Northwind Analytics", "ownerId": "00000000-0000-4000-a000-000000000000"}}, ) except ApiError as err: if err.code != 40000: raise print(err, json.dumps(err.error, indent=2)) ``` ## Codes you meet most The [error-code reference](/reference/errors/) lists every code of the current release with its name and HTTP status. These are the ones integrations meet most: | Code | Name | HTTP | Meaning | | -------------------------------------- | ------------------------------ | ---- | --------------------------------------------------------------------------------------------------------------- | | [50](/reference/errors/#code-50) | `ERROR_NOT_ENOUGH_PERMISSIONS` | 400 | The application lacks a permission | | [117](/reference/errors/#code-117) | `ERROR_PARAMETER_INVALID` | 400 | A parameter or value is invalid; also a repeated query key, a page size over 100 and input the API cannot store | | [118](/reference/errors/#code-118) | `ERROR_PARAMETER_UNKNOWN` | 400 | A parameter the endpoint does not take | | [119](/reference/errors/#code-119) | `ERROR_PARAMETER_MISSING` | 422 | A required value is missing | | [120](/reference/errors/#code-120) | `ERROR_RELATIONS_DOESNT_EXIST` | 422 | A related record does not exist, inside `field_errors` | | [143](/reference/errors/#code-143) | `ERROR_NO_ENTITY` | 404 | No record with that ID | | [182](/reference/errors/#code-182) | `ERROR_TOO_MANY_REQUESTS` | 429 | Too many requests; see `Retry-After` | | [1000](/reference/errors/#code-1000) | `ERROR_UNKNOWN` | 500 | Unexpected server error | | [2002](/reference/errors/#code-2002) | `ERROR_CONFLICTING_DATA` | 400 | The data conflicts, for example an `id` in the body that differs from the one in the URL | | [2006](/reference/errors/#code-2006) | `ERROR_UNKNOWN_TYPE` | 404 | No entity with that name in the path | | [40000](/reference/errors/#code-40000) | `ERROR_ENTITY_VALIDATION` | 422 | A record failed validation; see `entity_errors` and `field_errors` | | [40002](/reference/errors/#code-40002) | `ERROR_FIELD_VALIDATION` | 422 | One field failed validation, inside `field_errors` | ## Limits | Limit | Details | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 403 and 409 | Never used; permission problems and conflicts are 400 | | `message` | Written for people; match on `code` | | Input the API cannot store | `NaN` or `Infinity`, a NUL character (`\u0000`), an unpaired surrogate, a value too long to index or input nested too deeply: HTTP 400, code 117, with a message that names the problem | --- title: Rate limits and retries description: What the API answers when it throttles a client, how to retry with Retry-After and back-off, and where the monthly API call limits of each plan are stated. canonical: https://developers.coevera.com/concepts/rate-limits-and-retries/ section: concepts verification: schema lastVerified: 2026-10-16 publisher: Coevera --- # Rate limits and retries **Answer:** A throttled request answers HTTP 429 with code 182 and, when the limit computes one, a Retry-After header in seconds: wait that long, then retry. Without the header, back off exponentially. No per-minute limit is published. Monthly API call limits depend on your plan; see plans and limits. Never retry a 401. An integration that sends many requests will at some point be asked to slow down. Build the retry in from the start: it costs a few lines, and it is the difference between a slow sync and a failed one. ## How throttling looks A throttled request is refused with HTTP 429, code 182 `ERROR_TOO_MANY_REQUESTS`. In REST the body is the usual [error envelope](/concepts/errors/#the-error-envelope). In GraphQL it is not an `errors` list but a short body with `status`, `message` and `errorcode`, the same shape as a GraphQL `401`. When the limit computes a wait, a `Retry-After` header gives it in whole seconds. No per-minute number is published. Failed sign-ins are throttled on their own. After repeated failed sign-ins the credentials are locked for a while: every request answers 429, even with the right password, and without `Retry-After`. Stop at the first HTTP 401 and check the credentials ([authentication](/get-started/authentication/)): retrying a 401 only adds failed sign-ins. ## Retry after a 429 Every request in this helper goes through one function. On HTTP 429 it waits for `Retry-After`, or 5 seconds when the header is missing, and tries again, up to three attempts; any other error is raised with its code: **REST · cURL** ```bash # call METHOD PATH [BODY]: one REST request; prints the body. On HTTP 429 it waits as long as # Retry-After says (5 seconds when the header is missing) and tries again, three attempts in # all. On any other error it prints "HTTP , code : " and the # body, sets ERROR_CODE to the code and returns 1. call() { local attempt headers response status delay local args=(-X "$1" "$COEVERA_BASE$2" -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") if [ $# -ge 3 ]; then args+=(-H "Content-Type: application/json" -d "$3"); fi headers=$(mktemp) for attempt in 1 2 3; do response=$(curl -sS -D "$headers" -w '\n%{http_code}' "${args[@]}") status=${response##*$'\n'} response=${response%$'\n'*} if [ "$status" = 429 ] && [ "$attempt" -lt 3 ]; then delay=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9][0-9]*\).*/\1/p' "$headers") sleep "${delay:-5}" continue fi rm -f "$headers" if [ "$status" -ge 400 ]; then # The message is for people; branch on the numeric code. jq -r --arg status "$status" \ '"HTTP \($status), code \(.code) \(.name): \(.message | sub("^\\s+"; "") | sub("\\s+$"; ""))"' <<<"$response" echo "$response" ERROR_CODE=$(jq -r '.code' <<<"$response") return 1 fi echo "$response" return 0 done } ``` **REST · TypeScript** ```ts // An error the API answered: the HTTP status, and the code, name and message of its body. class ApiError extends Error { constructor( readonly status: number, readonly code: number, readonly errorName: string, message: string, readonly body: unknown, ) { super(`HTTP ${status}, code ${code} ${errorName}: ${message}`); } } const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); // call: one REST request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the // header is missing) and tries again, three attempts in all. Any other error is an ApiError. async function call(path: string, init: { method?: string; body?: unknown } = {}) { for (let attempt = 1; ; attempt++) { 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), }); if (res.status === 429 && attempt < 3) { await sleep(Number(res.headers.get("Retry-After")) || 5); continue; } const body = await res.json(); // The message is for people; branch on the numeric code. if (!res.ok) throw new ApiError(res.status, body.code, body.name, String(body.message).trim(), body); return body; } } ``` **REST · Python** ```python class ApiError(Exception): """An error the API answered: the HTTP status, and the code, name and message of its body.""" def __init__(self, status, body): self.status, self.body = status, body self.code, self.name = body.get("code"), body.get("name") message = str(body.get("message")).strip() super().__init__(f"HTTP {status}, code {self.code} {self.name}: {message}") def call(method, path, **kwargs): """One REST request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the header is missing) and tries again, three attempts in all. Any other error is an ApiError.""" for attempt in range(1, 4): response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs) if response.status_code == 429 and attempt < 3: time.sleep(float(response.headers.get("Retry-After") or 5)) continue body = response.json() # The message is for people; branch on the numeric code. if not response.ok: raise ApiError(response.status_code, body) return body ``` **GraphQL · cURL** ```bash # call REQUEST: POST one GraphQL request (a JSON body); prints the data. On HTTP 429 it waits as # long as Retry-After says (5 seconds when the header is missing) and tries again, three # attempts in all. On any error it prints "HTTP , code : " and # the error, sets ERROR_CODE to the api_error code (null when there is none) and returns 1. call() { local attempt headers response status delay headers=$(mktemp) for attempt in 1 2 3; do response=$(curl -sS -D "$headers" -w '\n%{http_code}' "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "$1") status=${response##*$'\n'} response=${response%$'\n'*} if [ "$status" = 429 ] && [ "$attempt" -lt 3 ]; then delay=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9][0-9]*\).*/\1/p' "$headers") sleep "${delay:-5}" continue fi rm -f "$headers" if [ "$status" != 200 ] || jq -e '.errors' <<<"$response" >/dev/null; then # The message is for people; branch on the numeric code. jq -r --arg status "$status" '.errors[0] | "HTTP \($status), code \(.api_error.code) \(.api_error.name // ""): \(.message | sub("^\\s+"; "") | sub("\\s+$"; ""))"' <<<"$response" jq '.errors[0]' <<<"$response" ERROR_CODE=$(jq -r '.errors[0].api_error.code' <<<"$response") return 1 fi jq '.data' <<<"$response" return 0 done } ``` **GraphQL · TypeScript** ```ts // An error the API answered: the HTTP status, and the code and name of api_error when the // error has one (a document the schema refuses has none). class ApiError extends Error { constructor( readonly status: number, readonly code: number | undefined, readonly errorName: string | undefined, message: string, readonly error: unknown, ) { super(`HTTP ${status}, code ${code ?? "none"} ${errorName ?? ""}: ${message}`); } } const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); // call: one GraphQL request. On HTTP 429 it waits as long as Retry-After says (5 seconds when // the header is missing) and tries again, three attempts in all. Any error is an ApiError. async function call(query: string, variables: object = {}) { for (let attempt = 1; ; attempt++) { const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }); if (res.status === 429 && attempt < 3) { await sleep(Number(res.headers.get("Retry-After")) || 5); continue; } const body = await res.json(); const error = body.errors?.[0]; // The message is for people; branch on the numeric code. if (!res.ok || error) { throw new ApiError(res.status, error?.api_error?.code, error?.api_error?.name, String(error?.message).trim(), error); } return body.data; } } ``` **GraphQL · Python** ```python class ApiError(Exception): """An error the API answered: the HTTP status, and the code and name of api_error when the error has one (a document the schema refuses has none).""" def __init__(self, status, code, name, message, error): super().__init__(f"HTTP {status}, code {code} {name}: {message}") self.status, self.code, self.name, self.error = status, code, name, error def call(query, variables=None): """One GraphQL request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the header is missing) and tries again, three attempts in all. Any error is an ApiError.""" for attempt in range(1, 4): response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30) if response.status_code == 429 and attempt < 3: time.sleep(float(response.headers.get("Retry-After") or 5)) continue body = response.json() error = (body.get("errors") or [None])[0] # The message is for people; branch on the numeric code. if not response.ok or error: api_error = (error or {}).get("api_error") or {} message = str((error or {}).get("message")).strip() raise ApiError(response.status_code, api_error.get("code"), api_error.get("name"), message, error) return body["data"] ``` In your own client: - Retry HTTP 429 after `Retry-After`. When the header is missing, back off exponentially, such as 1, 2, 4 and 8 seconds, with a cap. - Retry HTTP 500 and network errors with back-off too, but only reads, or writes you can check: a write that failed with 500 may have been applied. Read the record back before you send it again. - Never retry other 4xx answers unchanged. A 400 or 422 fails the same way the next time; fix the request. - Spread large jobs out instead of sending them in parallel, and use [batches](/concepts/batch-operations/) of up to 100 records to make fewer requests. ## GraphQL operation size One operation on the public GraphQL endpoint holds at most 200 mutations and at most 500 aliased fields; a fragment's aliases count at every place it is spread. A larger operation is refused before any of it runs, with HTTP 400 and only `errors[].message`, such as `Too many mutations in one operation: 201 (limit 200). Split them into several requests.` Split the work into several requests, or write many records with one `bulkUpdate…` mutation ([batch operations](/concepts/batch-operations/)). ## Plan limits The number of API calls a space may make each month depends on its Coevera plan: see [plans and limits](/get-started/plans-and-limits/#api-calls-per-month), which links the pricing page. The MCP server has limits of its own: see [MCP limits and errors](/ai/limits-and-errors/). ## Limits | Limit | Value | Details | | ------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | | Requests per minute | Not published | A throttled request answers HTTP 429, with `Retry-After` when the limit computes one | | API calls per month | Per plan | See [plans and limits](/get-started/plans-and-limits/#api-calls-per-month) | | Failed sign-ins | Throttled | Stop at the first 401; a locked sign-in answers 429 without `Retry-After` | | GraphQL operation | 200 mutations, 500 aliased fields | A larger operation answers HTTP 400 | --- title: Recipes overview description: Every recipe on this site, a complete program for one job, with the time it takes, its APIs and languages, and when it was last verified. canonical: https://developers.coevera.com/recipes/overview/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Recipes overview **Answer:** A recipe is a complete program for one job, in cURL, TypeScript and Python, for REST and GraphQL unless it says it uses one API. Every program is tested and reads its results back. This overview shows each recipe's time, APIs, languages and last verification. Each recipe does one job from start to finish, in numbered steps. Every step shows its request, taken from the recipe's tested program, and the response it returns. Pick REST or GraphQL and your language once, on any sample: every recipe follows that choice. A recipe that uses one API says so at the top and has no REST/GraphQL toggle. ## Recipes - [Create an account with contacts](/recipes/create-account-with-contacts/): Create an account, add its contacts in one batch with per-item results, and read them back, in REST or GraphQL, in cURL, TypeScript or Python. _10 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Create an opportunity](/recipes/create-opportunity/): Create an opportunity in the first step of a pipeline, linked to an account and a contact, set a custom field, and read the errors of a refused write, in REST or GraphQL. _20 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Products and line items](/recipes/products-and-line-items/): Create a product category and a product, set its list price, and add it to an opportunity as a line item with a price and a quantity, in REST or GraphQL. _20 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Attach a document](/recipes/attach-document/): Upload a file as base64 and attach it to an account, in two calls through CloudObjects or in one through CloudObjectRelations, in REST or GraphQL. _10 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Merge duplicates](/recipes/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. _15 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ ## Integrations Programs that connect Coevera to another system: a web form, a second contact list, a webhook receiver, a report. - [Lead from a web form](/recipes/lead-from-web-form/): Turn a form submission into a lead from your own server, with the visitor's details in the quick fields, an owner and a lead source, in REST or GraphQL. _10 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Two-way contact sync](/recipes/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_ - [Build a webhook receiver](/recipes/webhook-receiver/): A webhook receiver in TypeScript or Python that verifies signatures, answers fast, skips duplicates and survives retries, with a self-test and a cURL script to send it deliveries. _20 min | REST only | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Pipeline report](/recipes/pipeline-report/): Read every opportunity in a pipeline with filters, expand and cursor pagination together, and total the value and weighted value per step, in REST or GraphQL. _10 min | REST and GraphQL | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ - [Lead lifecycle (GraphQL)](/recipes/lead-lifecycle/): Create a lead, qualify it into an opportunity, move the opportunity to the next step and change its owner, with the GraphQL actions that REST does not have. _15 min | GraphQL only | cURL · TypeScript · Python | Verified in the docs sandbox on 2026-10-15_ ## Before you run one - You need an API application and its credentials: see the [quickstart](/get-started/quickstart/#get-api-credentials) and [authentication](/get-started/authentication/). Each recipe's **Before you start** lists the [permissions](/get-started/permissions-and-scopes/) it needs. - Recipes write records. Run them in a space without production data, and use each recipe's **Clean up** section afterwards. - Set your region and space ID in the context bar: every sample fills them in. ## Limits - Recipes use the standard entities and fields. Your custom fields and custom entities are in your space's own spec ([data model](/concepts/data-model/#your-own-fields-and-entities)). - The dates show when each recipe was last verified. Responses in your space carry your own ids and values. --- title: Create an account with contacts description: Create an account, add its contacts in one batch with per-item results, and read them back, in REST or GraphQL, in cURL, TypeScript or Python. canonical: https://developers.coevera.com/recipes/create-account-with-contacts/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Create an account with contacts **Answer:** Look up the owner by e-mail, create the account with name and owner_id, then add the contacts in one batch with account_relations, one primary relation each. Send rollback-method=1 (GraphQL ROLLBACK_FAILED) so one bad contact does not undo the others, check every item, and read the contacts back by account. Create **Northwind Analytics** with two contacts linked to it, owned by the same sales rep. You end with one account, two contacts and their account relations, read back from the API. ## 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). Do not relax it for new records; see [Validation](/concepts/validation/). | | Batch behaviour | Step 3 is a batch sent with `rollback-method=1` (GraphQL `rollbackMethod: ROLLBACK_FAILED`): it answers 200 even if a contact fails. The valid contacts are written, and a failed one comes back as an error object. The program checks every item. | | Context | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`. | The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Clients`](/reference/rest/clients/#list), [`POST /entities/Accounts`](/reference/rest/accounts/#create), [`POST /entities/Contacts/batch-modify`](/reference/rest/contacts/#batch-modify), [`GET /entities/Contacts`](/reference/rest/contacts/#list); GraphQL [`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria), [`createAccount`](/reference/graphql/account/#m-createAccount), [`bulkUpdateContact`](/reference/graphql/contact/#m-bulkUpdateContact), [`ContactRepository.getByCriteria`](/reference/graphql/contact/#q-getByCriteria). ## Set up a helper Every step below uses this setup, so the steps show only what changes. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); 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}: ${body.message}`); return body; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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')}: {body.get('message')}") return body ``` **GraphQL · cURL** ```bash COEVERA_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" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 in `Clients`, the API name for users. The programs encode the address: a raw `+` in a query string arrives as a space. **REST · cURL** ```bash owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \ --data-urlencode "filter[email]=dana.whitfield@example.com" \ -d load-only=id,name,default_unit_id \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$owner" OWNER_ID=$(jq -er '.data[0].id' <<<"$owner") ``` **REST · TypeScript** ```ts // URLSearchParams encodes the address: a raw "+" in a query string arrives as a space. const lookup = new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name,default_unit_id", }); const owners = await coevera(`/entities/Clients?${lookup}`); const ownerId: string | undefined = owners.data[0]?.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.data[0].name, ownerId); ``` **REST · Python** ```python owners = coevera( "GET", "/entities/Clients", params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name,default_unit_id"}, ) if not owners["data"]: sys.exit("No user with that e-mail in this space.") owner_id = owners["data"][0]["id"] print("Owner:", owners["data"][0]["name"], owner_id) ``` **GraphQL · cURL** ```bash owner=$(jq -n '{ query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$owner" OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner") ``` **GraphQL · TypeScript** ```ts const { data: found } = await gql(`{ entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } }`); const ownerId: string | undefined = found.entities.client.getByCriteria.edges[0]?.node.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", found.entities.client.getByCriteria.edges[0].node.name, ownerId); ``` **GraphQL · Python** ```python found = gql(""" { entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } } """) edges = found["entities"]["client"]["getByCriteria"]["edges"] if not edges: sys.exit("No user with that e-mail in this space.") owner_id = edges[0]["node"]["id"] print("Owner:", edges[0]["node"]["name"], owner_id) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0d000000-0000-4000-a000-000000000001", "default_unit_id": "05000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "client": { "getByCriteria": { "edges": [ { "node": { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 2. Create the account `name` and `owner_id` are required: without either, the API answers 422. The sales unit (`unit_id`) defaults to the owner's default unit. **REST · cURL** ```bash account=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Accounts" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- <&2 exit 1 fi ``` **REST · TypeScript** ```ts const batch = await coevera("/entities/Contacts/batch-modify?rollback-method=1", { method: "POST", body: [ { first_name: "Ava", last_name: "Lindqvist", email1: "ava.lindqvist@example.com", owner_id: ownerId, account_relations: [{ account_id: accountId, position: "Head of Data", is_primary: true }], }, { first_name: "Marco", last_name: "Ruiz", email1: "marco.ruiz@example.com", owner_id: ownerId, account_relations: [{ account_id: accountId, position: "CFO", is_primary: true }], }, ], }); // With rollback-method=1 a failed contact comes back as an error object in data: check every item. const failed = batch.data.filter((item: unknown) => typeof item !== "string"); if (failed.length) throw new Error(`${failed.length} contact(s) failed: ${JSON.stringify(failed)}`); console.log("Contacts:", batch.data); ``` **REST · Python** ```python batch = coevera( "POST", "/entities/Contacts/batch-modify", params={"rollback-method": 1}, json=[ { "first_name": "Ava", "last_name": "Lindqvist", "email1": "ava.lindqvist@example.com", "owner_id": owner_id, "account_relations": [ {"account_id": account_id, "position": "Head of Data", "is_primary": True} ], }, { "first_name": "Marco", "last_name": "Ruiz", "email1": "marco.ruiz@example.com", "owner_id": owner_id, "account_relations": [{"account_id": account_id, "position": "CFO", "is_primary": True}], }, ], ) # With rollback-method=1 a failed contact comes back as an error object in data: check every item. failed = [item for item in batch["data"] if not isinstance(item, str)] if failed: sys.exit(f"{len(failed)} contact(s) failed: {json.dumps(failed)}") print("Contacts:", batch["data"]) ``` **GraphQL · cURL** ```bash contacts=$(jq -n --arg owner "$OWNER_ID" --arg account "$ACCOUNT_ID" '{ query: "mutation AddContacts($input: [CreateOrUpdateContactInput!]!) { bulkUpdateContact(input: $input, rollbackMethod: ROLLBACK_FAILED) { result { created { index id } errors { index code message } } } }", variables: { input: [ { firstName: "Ava", lastName: "Lindqvist", email1: "ava.lindqvist@example.com", ownerId: $owner, accountRelations: [{ accountId: $account, position: "Head of Data", isPrimary: true }] }, { firstName: "Marco", lastName: "Ruiz", email1: "marco.ruiz@example.com", ownerId: $owner, accountRelations: [{ accountId: $account, position: "CFO", isPrimary: true }] } ] } }' | gql) echo "$contacts" failed=$(jq '.data.bulkUpdateContact.result.errors | length' <<<"$contacts") if [ "$failed" -ne 0 ]; then echo "$failed contact(s) failed" >&2 exit 1 fi ``` **GraphQL · TypeScript** ```ts const { data: added } = await gql( `mutation AddContacts($input: [CreateOrUpdateContactInput!]!) { bulkUpdateContact(input: $input, rollbackMethod: ROLLBACK_FAILED) { result { created { index id } errors { index code message } } } }`, { input: [ { firstName: "Ava", lastName: "Lindqvist", email1: "ava.lindqvist@example.com", ownerId, accountRelations: [{ accountId, position: "Head of Data", isPrimary: true }], }, { firstName: "Marco", lastName: "Ruiz", email1: "marco.ruiz@example.com", ownerId, accountRelations: [{ accountId, position: "CFO", isPrimary: true }], }, ], }, ); const { created: contactIds, errors } = added.bulkUpdateContact.result; if (errors.length) throw new Error(`${errors.length} contact(s) failed: ${JSON.stringify(errors)}`); console.log("Contacts:", contactIds); ``` **GraphQL · Python** ```python added = gql( """ mutation AddContacts($input: [CreateOrUpdateContactInput!]!) { bulkUpdateContact(input: $input, rollbackMethod: ROLLBACK_FAILED) { result { created { index id } errors { index code message } } } } """, { "input": [ { "firstName": "Ava", "lastName": "Lindqvist", "email1": "ava.lindqvist@example.com", "ownerId": owner_id, "accountRelations": [ {"accountId": account_id, "position": "Head of Data", "isPrimary": True} ], }, { "firstName": "Marco", "lastName": "Ruiz", "email1": "marco.ruiz@example.com", "ownerId": owner_id, "accountRelations": [{"accountId": account_id, "position": "CFO", "isPrimary": True}], }, ] }, ) result = added["bulkUpdateContact"]["result"] if result["errors"]: sys.exit(f"{len(result['errors'])} contact(s) failed: {json.dumps(result['errors'])}") print("Contacts:", result["created"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "data": [ "0c000000-0000-4000-a000-000000000001", "0c000000-0000-4000-a000-000000000002" ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "bulkUpdateContact": { "result": { "created": [ { "index": 0, "id": "0c000000-0000-4000-a000-000000000001" }, { "index": 1, "id": "0c000000-0000-4000-a000-000000000002" } ], "errors": [] } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ > **Warning:** Without `rollback-method=1`, one failed contact fails the whole batch and nothing is written: > REST answers 422. In GraphQL, a `bulkUpdateContact` without `rollbackMethod` uses > `ROLLBACK_ALL` and answers HTTP 200 with `errors` and no `data`. Send `ROLLBACK_FAILED` to get > per-item results. [Batch operations](/concepts/batch-operations/#partial-failures) shows both > modes. ## 4. Read them back Filter the contacts by the account they belong to, and check them here rather than in the batch response. REST filters on `account_relations.account_id`; GraphQL on `accountRelations: { accountId: … }`. **REST · cURL** ```bash curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Contacts" \ --data-urlencode "filter[account_relations.account_id]=$ACCOUNT_ID" \ -d load-only=id,first_name,last_name,email1 \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts const contacts = await coevera( `/entities/Contacts?filter[account_relations.account_id]=${accountId}&load-only=id,first_name,last_name,email1`, ); console.log(contacts.total, "contacts on the account:", JSON.stringify(contacts.data, null, 2)); ``` **REST · Python** ```python contacts = coevera( "GET", "/entities/Contacts", params={ "filter[account_relations.account_id]": account_id, "load-only": "id,first_name,last_name,email1", }, ) print(contacts.get("total"), "contacts on the account:", json.dumps(contacts["data"], indent=2)) ``` **GraphQL · cURL** ```bash jq -n --arg account "$ACCOUNT_ID" '{ query: "query ReadBack($account: String!) { entities { contact { getByCriteria(filter: { accountRelations: { accountId: { eq: [$account] } } }) { totalCount edges { node { id firstName lastName email1 } } } } } }", variables: { account: $account } }' | gql ``` **GraphQL · TypeScript** ```ts const { data: readBack } = await gql( `query ReadBack($accountId: String!) { entities { contact { getByCriteria(filter: { accountRelations: { accountId: { eq: [$accountId] } } }) { totalCount edges { node { id firstName lastName email1 } } } } } }`, { accountId }, ); const contacts = readBack.entities.contact.getByCriteria; console.log(contacts.totalCount, "contacts on the account:", JSON.stringify(contacts.edges, null, 2)); ``` **GraphQL · Python** ```python read_back = gql( """ query ReadBack($accountId: String!) { entities { contact { getByCriteria(filter: { accountRelations: { accountId: { eq: [$accountId] } } }) { totalCount edges { node { id firstName lastName email1 } } } } } } """, {"accountId": account_id}, ) contacts = read_back["entities"]["contact"]["getByCriteria"] print(contacts["totalCount"], "contacts on the account:", json.dumps(contacts["edges"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 2, "page_info": { "start_cursor": "WyIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0c000000-0000-4000-a000-000000000001", "email1": "ava.lindqvist@example.com", "first_name": "Ava", "last_name": "Lindqvist" }, { "id": "0c000000-0000-4000-a000-000000000002", "email1": "marco.ruiz@example.com", "first_name": "Marco", "last_name": "Ruiz" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "contact": { "getByCriteria": { "totalCount": 2, "edges": [ { "node": { "id": "0c000000-0000-4000-a000-000000000001", "firstName": "Ava", "lastName": "Lindqvist", "email1": "ava.lindqvist@example.com" } }, { "node": { "id": "0c000000-0000-4000-a000-000000000002", "firstName": "Marco", "lastName": "Ruiz", "email1": "marco.ruiz@example.com" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## What to check - Step 1 returns exactly one user. An empty `data` (GraphQL `edges`) means the e-mail is not a user of the space. - Step 2 answers **201** (GraphQL: 200), and `data.id` (GraphQL `createAccount.account.id`) is the account ID the next steps use. - Step 3 answers 200 with `"success": true`, and `data` holds two IDs, in the order you sent the contacts. With `rollback-method=1`, a failed contact makes `success` false and leaves an object with `code`, `name` and `message` in its place; the other contact is still written. In GraphQL, `result.created` lists the written items and `result.errors` the failed ones, each by `index`. - Step 4 returns `total: 2` (GraphQL `totalCount: 2`). If it returns 0, the relation is missing: check the `account_id` you sent. - Run the program twice and you get two accounts named Northwind Analytics and four contacts: the API does not refuse duplicates. To make it safe to repeat, look the account up by `name` first and reuse it. ## Clean up The program leaves the account and its two contacts in your space. To remove them, send their IDs to `POST /entities/Contacts/batch-delete` and `POST /entities/Accounts/batch-delete`, then delete their rows in `ContactAccountRelations` the same way. > **Note:** Deleting a contact or an account leaves its `ContactAccountRelations` rows active, still > pointing at the deleted records. Find them with `filter[account_id]` and delete them too. > Deletes are soft: deleted records stay readable with `include-deleted=true`. ## Limits - The program stops at the first error and does not retry. - The API sets no batch size limit; a batch of 500 items is accepted. - It creates one account. For many accounts, create them in one `Accounts/batch-modify` call first, then send their contacts in a second batch. --- title: Create an opportunity description: Create an opportunity in the first step of a pipeline, linked to an account and a contact, set a custom field, and read the errors of a refused write, in REST or GraphQL. canonical: https://developers.coevera.com/recipes/create-opportunity/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Create an opportunity **Answer:** Look up the owner, the account, the contact, the pipeline's first step and the base currency. Create the opportunity with name, owner_id, step_id, closing_date and value, plus its account and contact relations. Write a custom field under its cf_ api_name. A refused write answers 422 with field_errors and changes nothing. Create **Contoso fleet telemetry rollout**, an opportunity for Contoso Freight with Lena Okafor as its contact, owned by Dana Whitfield, in the first step of the pipeline. Then set its **Competitor** custom field, see what a refused update returns, and read the opportunity back. ## Before you start | | | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Permissions | **Full Access** to **Opportunities**, and **Read Only Access** to **Accounts** and **Contacts**, on the **Records** tab; **Read Only Access** to **Pipelines** and **Currencies** on the **Administration** tab. The owner lookup also reads users (`Clients`). | | `validation-level` | The default (`0`, full validation). | | Batch behaviour | No batches: every write is one record. | | Custom field | The program sets **Competitor** (`cf_competitor`), a single-line text field on Opportunity. Use a field of your own space: `GET /entities/Fields?filter[entity_name]=Opportunity` lists them with their `api_name`; see [Fields and custom fields](/concepts/fields-and-custom-fields/#find-a-fields-api-name). | | Context | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`. | The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Clients`](/reference/rest/clients/#list), [`GET /entities/Accounts`](/reference/rest/accounts/#list), [`GET /entities/Contacts`](/reference/rest/contacts/#list), [`GET /entities/Steps`](/reference/rest/steps/#list), [`GET /entities/Currencies`](/reference/rest/currencies/#list), [`POST /entities/Opportunities`](/reference/rest/opportunities/#create), [`PATCH /entities/Opportunities/{id}`](/reference/rest/opportunities/#update), [`GET /entities/Opportunities`](/reference/rest/opportunities/#list); GraphQL [`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria), [`AccountRepository.getByCriteria`](/reference/graphql/account/#q-getByCriteria), [`ContactRepository.getByCriteria`](/reference/graphql/contact/#q-getByCriteria), [`StepRepository.getByCriteria`](/reference/graphql/step/#q-getByCriteria), [`CurrencyRepository.getByCriteria`](/reference/graphql/currency/#q-getByCriteria), [`createOpportunity`](/reference/graphql/opportunity/#m-createOpportunity), [`updateOpportunity`](/reference/graphql/opportunity/#m-updateOpportunity), [`OpportunityRepository.getById`](/reference/graphql/opportunity/#q-getById). ## Set up a helper Every step below uses this setup. In TypeScript and Python, the helper keeps the body of a refused request, so step 8 can read its errors. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // The body of a refused request. Messages can start with a newline: trim them. interface ErrorBody { code: number; name: string; message: string; entity_errors: { code: number; name: string; message: string }[]; field_errors: { field_name: string; errors: { code: number; name: string; message: string }[] }[]; } class ApiError extends Error { constructor( readonly status: number, readonly body: ErrorBody, ) { super(`HTTP ${status} ${body.name}: ${body.message.trim()}`); } } 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 ApiError(res.status, body); return body; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) class ApiError(Exception): """A refused request. body holds code, name, message, entity_errors and field_errors.""" def __init__(self, status, body): super().__init__(f"HTTP {status} {body.get('name')}: {str(body.get('message')).strip()}") self.status = status self.body = body def coevera(method, path, **kwargs): response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs) body = response.json() if not response.ok: raise ApiError(response.status_code, body) return body ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" # gql_raw: POST the GraphQL request read from stdin and print the response as it is. gql_raw() { curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- } # gql: the same, but fail on errors: GraphQL reports them in the body, often with HTTP 200. gql() { local response response=$(gql_raw) if jq -e '.errors' <<<"$response" >/dev/null; then echo "$response" >&2 return 1 fi echo "$response" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // A refused mutation: each error carries api_error with code, name, entity_errors and // field_errors. Messages can start with a newline: trim them. interface GraphQLError { message: string; api_error?: { code: number; name: string; entity_errors: { code: number; name: string; message: string }[]; field_errors: { field_name: string; errors: { code: number; name: string; message: string }[] }[]; }; } class ApiError extends Error { constructor(readonly errors: GraphQLError[]) { super(errors.map((e) => e.message.trim()).join("; ")); } } // 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 (body.errors) throw new ApiError(body.errors); if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body)}`); return body.data; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) class ApiError(Exception): """A refused mutation. Each error carries api_error: code, name, entity_errors, field_errors.""" def __init__(self, errors): super().__init__("; ".join(e["message"].strip() for e in errors)) self.errors = errors 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 body.get("errors"): raise ApiError(body["errors"]) if not response.ok: sys.exit(f"HTTP {response.status_code}: {json.dumps(body)}") return body["data"] ``` ## 1. Find the owner Every opportunity needs an owner. Look the sales rep up by e-mail in `Clients`, the API name for users. **REST · cURL** ```bash owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \ --data-urlencode "filter[email]=dana.whitfield@example.com" \ -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$owner" OWNER_ID=$(jq -er '.data[0].id' <<<"$owner") ``` **REST · TypeScript** ```ts // URLSearchParams encodes the address: a raw "+" in a query string arrives as a space. const owners = await coevera( `/entities/Clients?${new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name" })}`, ); const ownerId: string | undefined = owners.data[0]?.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.data[0].name, ownerId); ``` **REST · Python** ```python owners = coevera( "GET", "/entities/Clients", params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"}, ) if not owners["data"]: sys.exit("No user with that e-mail in this space.") owner_id = owners["data"][0]["id"] print("Owner:", owners["data"][0]["name"], owner_id) ``` **GraphQL · cURL** ```bash owner=$(jq -n '{ query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$owner" OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner") ``` **GraphQL · TypeScript** ```ts const owners = await gql(`{ entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } }`); const owner = owners.entities.client.getByCriteria.edges[0]?.node; if (!owner) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owner.name, owner.id); ``` **GraphQL · Python** ```python owners = gql(""" { entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } } """) edges = owners["entities"]["client"]["getByCriteria"]["edges"] if not edges: sys.exit("No user with that e-mail in this space.") owner = edges[0]["node"] print("Owner:", owner["name"], owner["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "client": { "getByCriteria": { "edges": [ { "node": { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 2. Find the account Look the account up by its e-mail address, `email1`. **REST · cURL** ```bash account=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ --data-urlencode "filter[email1]=contoso@example.com" \ -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$account" ACCOUNT_ID=$(jq -er '.data[0].id' <<<"$account") ``` **REST · TypeScript** ```ts const accounts = await coevera( `/entities/Accounts?${new URLSearchParams({ "filter[email1]": "contoso@example.com", "load-only": "id,name" })}`, ); const accountId: string | undefined = accounts.data[0]?.id; if (!accountId) throw new Error("No account with that e-mail."); console.log("Account:", accounts.data[0].name, accountId); ``` **REST · Python** ```python accounts = coevera( "GET", "/entities/Accounts", params={"filter[email1]": "contoso@example.com", "load-only": "id,name"}, ) if not accounts["data"]: sys.exit("No account with that e-mail.") account_id = accounts["data"][0]["id"] print("Account:", accounts["data"][0]["name"], account_id) ``` **GraphQL · cURL** ```bash account=$(jq -n '{ query: "{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: [\"contoso@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$account" ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$account") ``` **GraphQL · TypeScript** ```ts const accounts = await gql(`{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: ["contoso@example.com"] } }) { edges { node { id name } } } } } }`); const account = accounts.entities.account.getByCriteria.edges[0]?.node; if (!account) throw new Error("No account with that e-mail."); console.log("Account:", account.name, account.id); ``` **GraphQL · Python** ```python accounts = gql(""" { entities { account { getByCriteria(first: 1, filter: { email1: { eq: ["contoso@example.com"] } }) { edges { node { id name } } } } } } """) edges = accounts["entities"]["account"]["getByCriteria"]["edges"] if not edges: sys.exit("No account with that e-mail.") account = edges[0]["node"] print("Account:", account["name"], account["id"]) ``` **Recorded response · REST · 200 OK** ```json { "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", "name": "Contoso Freight" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "account": { "getByCriteria": { "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Contoso Freight" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 3. Find the contact Look the contact up the same way. **REST · cURL** ```bash contact=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Contacts" \ --data-urlencode "filter[email1]=lena.okafor@example.com" \ -d load-only=id,first_name,last_name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$contact" CONTACT_ID=$(jq -er '.data[0].id' <<<"$contact") ``` **REST · TypeScript** ```ts const contacts = await coevera( `/entities/Contacts?${new URLSearchParams({ "filter[email1]": "lena.okafor@example.com", "load-only": "id,first_name,last_name" })}`, ); const contactId: string | undefined = contacts.data[0]?.id; if (!contactId) throw new Error("No contact with that e-mail."); console.log("Contact:", contacts.data[0].first_name, contacts.data[0].last_name, contactId); ``` **REST · Python** ```python contacts = coevera( "GET", "/entities/Contacts", params={"filter[email1]": "lena.okafor@example.com", "load-only": "id,first_name,last_name"}, ) if not contacts["data"]: sys.exit("No contact with that e-mail.") contact_id = contacts["data"][0]["id"] print("Contact:", contacts["data"][0]["first_name"], contacts["data"][0]["last_name"], contact_id) ``` **GraphQL · cURL** ```bash contact=$(jq -n '{ query: "{ entities { contact { getByCriteria(first: 1, filter: { email1: { eq: [\"lena.okafor@example.com\"] } }) { edges { node { id firstName lastName } } } } } }" }' | gql) echo "$contact" CONTACT_ID=$(jq -er '.data.entities.contact.getByCriteria.edges[0].node.id' <<<"$contact") ``` **GraphQL · TypeScript** ```ts const contacts = await gql(`{ entities { contact { getByCriteria(first: 1, filter: { email1: { eq: ["lena.okafor@example.com"] } }) { edges { node { id firstName lastName } } } } } }`); const contact = contacts.entities.contact.getByCriteria.edges[0]?.node; if (!contact) throw new Error("No contact with that e-mail."); console.log("Contact:", contact.firstName, contact.lastName, contact.id); ``` **GraphQL · Python** ```python contacts = gql(""" { entities { contact { getByCriteria(first: 1, filter: { email1: { eq: ["lena.okafor@example.com"] } }) { edges { node { id firstName lastName } } } } } } """) edges = contacts["entities"]["contact"]["getByCriteria"]["edges"] if not edges: sys.exit("No contact with that e-mail.") contact = edges[0]["node"] print("Contact:", contact["firstName"], contact["lastName"], contact["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0c000000-0000-4000-a000-000000000001", "first_name": "Lena", "last_name": "Okafor" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "contact": { "getByCriteria": { "edges": [ { "node": { "id": "0c000000-0000-4000-a000-000000000001", "firstName": "Lena", "lastName": "Okafor" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 4. Find the first step of the pipeline An opportunity always sits in a step of a pipeline. Filter the steps by the pipeline's name (`filter[pipeline.name]`) and order them by `sort_order`: the first one is where new opportunities start. The program looks for the pipeline called Default pipeline; use the name of yours. `percent` is the step's probability. **REST · cURL** ```bash # The first step of the pipeline, by the pipeline's name: the lowest sort_order. step=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Steps" \ --data-urlencode "filter[pipeline.name]=Default pipeline" \ -d order-by=sort_order -d first=1 -d load-only=id,name,percent \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$step" STEP_ID=$(jq -er '.data[0].id' <<<"$step") ``` **REST · TypeScript** ```ts // The first step of the pipeline, by the pipeline's name: the lowest sort_order. const steps = await coevera( `/entities/Steps?${new URLSearchParams({ "filter[pipeline.name]": "Default pipeline", "order-by": "sort_order", first: "1", "load-only": "id,name,percent", })}`, ); const stepId: string | undefined = steps.data[0]?.id; if (!stepId) throw new Error("No pipeline with that name, or it has no steps."); console.log("Step:", steps.data[0].name, stepId); ``` **REST · Python** ```python # The first step of the pipeline, by the pipeline's name: the lowest sort_order. steps = coevera( "GET", "/entities/Steps", params={ "filter[pipeline.name]": "Default pipeline", "order-by": "sort_order", "first": 1, "load-only": "id,name,percent", }, ) if not steps["data"]: sys.exit("No pipeline with that name, or it has no steps.") step_id = steps["data"][0]["id"] print("Step:", steps["data"][0]["name"], step_id) ``` **GraphQL · cURL** ```bash # The first step of the pipeline, by the pipeline's name: the lowest sortOrder. step=$(jq -n '{ query: "{ entities { step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: [\"Default pipeline\"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name percent } } } } } }" }' | gql) echo "$step" STEP_ID=$(jq -er '.data.entities.step.getByCriteria.edges[0].node.id' <<<"$step") ``` **GraphQL · TypeScript** ```ts // The first step of the pipeline, by the pipeline's name: the lowest sortOrder. const steps = await gql(`{ entities { step { getByCriteria( first: 1 filter: { pipeline: { name: { eq: ["Default pipeline"] } } } orderBy: [{ sortOrder: Asc }] ) { edges { node { id name percent } } } } } }`); const step = steps.entities.step.getByCriteria.edges[0]?.node; if (!step) throw new Error("No pipeline with that name, or it has no steps."); console.log("Step:", step.name, step.id); ``` **GraphQL · Python** ```python # The first step of the pipeline, by the pipeline's name: the lowest sortOrder. steps = gql(""" { entities { step { getByCriteria( first: 1 filter: { pipeline: { name: { eq: ["Default pipeline"] } } } orderBy: [{ sortOrder: Asc }] ) { edges { node { id name percent } } } } } } """) edges = steps["entities"]["step"]["getByCriteria"]["edges"] if not edges: sys.exit("No pipeline with that name, or it has no steps.") step = edges[0]["node"] print("Step:", step["name"], step["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 3, "page_info": { "start_cursor": "WzAsICIwNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WzAsICIwNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": true }, "data": [ { "id": "07000000-0000-4000-a000-000000000001", "name": "Validate", "percent": 30 } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "step": { "getByCriteria": { "edges": [ { "node": { "id": "07000000-0000-4000-a000-000000000001", "name": "Validate", "percent": 30 } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 5. Find the base currency The opportunity's `value` needs a currency. The space's base currency is the one with `is_base: true`. **REST · cURL** ```bash currency=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Currencies" \ -d "filter[is_base]=true" -d load-only=id,code \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$currency" CURRENCY_ID=$(jq -er '.data[0].id' <<<"$currency") ``` **REST · TypeScript** ```ts const currencies = await coevera("/entities/Currencies?filter[is_base]=true&load-only=id,code"); const currencyId: string = currencies.data[0].id; console.log("Base currency:", currencies.data[0].code, currencyId); ``` **REST · Python** ```python currencies = coevera( "GET", "/entities/Currencies", params={"filter[is_base]": "true", "load-only": "id,code"} ) currency_id = currencies["data"][0]["id"] print("Base currency:", currencies["data"][0]["code"], currency_id) ``` **GraphQL · cURL** ```bash currency=$(jq -n '{ query: "{ entities { currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } }" }' | gql) echo "$currency" CURRENCY_ID=$(jq -er '.data.entities.currency.getByCriteria.edges[0].node.id' <<<"$currency") ``` **GraphQL · TypeScript** ```ts const currencies = await gql(`{ entities { currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } }`); const currency = currencies.entities.currency.getByCriteria.edges[0].node; console.log("Base currency:", currency.code, currency.id); ``` **GraphQL · Python** ```python currencies = gql(""" { entities { currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } } """) currency = currencies["entities"]["currency"]["getByCriteria"]["edges"][0]["node"] print("Base currency:", currency["code"], currency["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwNDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwNDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "04000000-0000-4000-a000-000000000001", "code": "USD" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "currency": { "getByCriteria": { "edges": [ { "node": { "id": "04000000-0000-4000-a000-000000000001", "code": "USD" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 6. Create the opportunity `name`, `owner_id`, `step_id`, `closing_date` and `value` are required. `closing_date` is a date in `YYYY-MM-DD` form; another format, such as `29/01/2027`, is refused with 422. `account_relations` and `contact_relations` link the records, and the relation marked `is_primary: true` becomes the opportunity's primary account or contact. The account is required too: without `account_relations`, the create answers 422. `value` holds `base_value`, `value_foreign` and `currency_id`. The published REST spec lists all three as required; the [Opportunities reference](/reference/rest/opportunities/#create) shows the rules below. The API needs `currency_id`: without it, the create answers 422 (`Missing parameter currency_id in field value`). Given `base_value` and `currency_id`, it computes `value_foreign`, which is what the GraphQL program relies on. **REST · cURL** ```bash # In the base currency, value_foreign equals base_value. opportunity=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Opportunities" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- </dev/null echo "Competitor set." ``` **REST · TypeScript** ```ts // A custom field is written like any other field, under its api_name. await coevera(`/entities/Opportunities/${opportunityId}`, { method: "PATCH", body: { cf_competitor: "In-house BI team" }, }); console.log("Competitor set."); ``` **REST · Python** ```python # A custom field is written like any other field, under its api_name. coevera("PATCH", f"/entities/Opportunities/{opportunity_id}", json={"cf_competitor": "In-house BI team"}) print("Competitor set.") ``` **GraphQL · cURL** ```bash # Custom fields travel as one JSON string, keyed by api_name: tojson builds it. jq -n --arg id "$OPPORTUNITY_ID" '{ query: "mutation UpdateCompetitor($input: UpdateOpportunityInput!) { updateOpportunity(input: $input) { opportunity { id customFields(apiNames: [\"cf_competitor\"]) } } }", variables: { input: { id: $id, customFields: ({ cf_competitor: "In-house BI team" } | tojson) } } }' | gql ``` **GraphQL · TypeScript** ```ts // Custom fields travel as one JSON string, keyed by api_name. const updated = await gql( `mutation UpdateCompetitor($input: UpdateOpportunityInput!) { updateOpportunity(input: $input) { opportunity { id customFields(apiNames: ["cf_competitor"]) } } }`, { input: { id: opportunityId, customFields: JSON.stringify({ cf_competitor: "In-house BI team" }) } }, ); console.log("Custom fields:", updated.updateOpportunity.opportunity.customFields); ``` **GraphQL · Python** ```python # Custom fields travel as one JSON string, keyed by api_name. updated = gql( """ mutation UpdateCompetitor($input: UpdateOpportunityInput!) { updateOpportunity(input: $input) { opportunity { id customFields(apiNames: ["cf_competitor"]) } } } """, {"input": {"id": opportunity_id, "customFields": json.dumps({"cf_competitor": "In-house BI team"})}}, ) print("Custom fields:", updated["updateOpportunity"]["opportunity"]["customFields"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "data": { "is_delete_protected": false, "id": "0b000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.662838+00:00", "created": "2026-10-15 09:00:00.000000+00:00", "active_quote": null, "currency_exchange_rates_list": null, "lead_source": null, "oppty_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/OpportunityTypes/00000000-0000-4000-a000-000000000001", "owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001", "product_currency": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Currencies/04000000-0000-4000-a000-000000000001", "product_price_list": null, "reason_of_close": null, "step": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Steps/07000000-0000-4000-a000-000000000001", "unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001", "active_quote_id": null, "closing_date": "2027-03-31", "currency_exchange_rates_list_id": null, "description": "", "is_archived": false, "is_value_auto_calculate": false, "label_flag": 0, "last_ai_buying_center_applied": null, "lead_source_id": null, "name": "Contoso fleet telemetry rollout", "oppty_type_id": "00000000-0000-4000-a000-000000000001", "owner_id": "0d000000-0000-4000-a000-000000000001", "product_currency_id": "04000000-0000-4000-a000-000000000001", "product_price_list_id": null, "product_sections": null, "quick_account_email": "", "quick_account_name": "", "quick_account_phone": "", "quick_contact_name": "", "quick_email": "", "quick_phone": "", "ranking": 50, "reason_of_close_description": "", "reason_of_close_id": null, "share_mode": 0, "step_id": "07000000-0000-4000-a000-000000000001", "table_name": "oppty", "unit_id": "05000000-0000-4000-a000-000000000001", "was_qualified": false, "revision": 591, "value": { "base_value": 42000, "currency_id": "04000000-0000-4000-a000-000000000001", "value_foreign": 42000 }, "oppty_recurrence": null, "revenue_schedule": null, "product_relations": [], "primary_contact": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Contacts/0c000000-0000-4000-a000-000000000001", "primary_account": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Accounts/0a000000-0000-4000-a000-000000000001", "contact_relations": [ "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/LeadOpptyContactRelations/01000000-0000-4000-a000-000000000001" ], "account_relations": [ "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/LeadOpptyAccountRelations/0f000000-0000-4000-a000-000000000001" ], "documents": [], "quote_relations": [], "status": 1, "qualify_date": null, "won_date": null, "lost_date": null, "days_in_step": 0, "is_favorite": false, "sharing_units": [], "sharing_clients": [], "tags": [], "static_profiles": [], "formatted_name": "Contoso fleet telemetry rollout", "modified_by_user": null, "cf_competitor": "In-house BI team" } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "updateOpportunity": { "opportunity": { "id": "0b000000-0000-4000-a000-000000000001", "customFields": "{\"cfCompetitor\": \"In-house BI team\"}" } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 8. Read the errors An update the API refuses: `name` is required, so an empty one fails validation. REST answers **422**; GraphQL answers HTTP 200 with `errors`, each carrying the same body in `api_error`, and `null` data. The body holds: - `code` and `name`: `40000` `ERROR_ENTITY_VALIDATION` for a record that fails validation; - `entity_errors`: problems with the record as a whole, each with a `code` and a `message`; - `field_errors`: one entry per field, with `field_name` and the `errors` on it, here `119` `ERROR_PARAMETER_MISSING` on `name`. Messages can start with a newline: trim them before you show them. A refused write changes nothing, which the next step confirms. [Errors](/concepts/errors/) lists the envelope, the statuses and the codes you meet most. **REST · cURL** ```bash # An update the API refuses: name is required, so an empty one fails validation. No # --fail-with-body here: the error body carries http_status, code and the field errors. refused=$(curl -sS -X PATCH "$COEVERA_BASE/entities/Opportunities/$OPPORTUNITY_ID" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "name": "" }') echo "$refused" # Messages can start with a newline: trim them. jq -r '((.entity_errors // [])[] | "record: \(.code) \(.message | sub("^\\s+"; ""))"), ((.field_errors // [])[] | .field_name as $f | .errors[] | "\($f): \(.code) \(.message | sub("^\\s+"; ""))")' <<<"$refused" ``` **REST · TypeScript** ```ts // An update the API refuses: name is required, so an empty one fails validation. try { await coevera(`/entities/Opportunities/${opportunityId}`, { method: "PATCH", body: { name: "" } }); console.log("The update was accepted."); } catch (err) { if (!(err instanceof ApiError) || err.status !== 422) throw err; console.log("Refused:", err.status, err.body.code, err.body.name); for (const e of err.body.entity_errors) console.log(` record: ${e.code} ${e.message.trim()}`); for (const f of err.body.field_errors) { for (const e of f.errors) console.log(` ${f.field_name}: ${e.code} ${e.message.trim()}`); } } ``` **REST · Python** ```python # An update the API refuses: name is required, so an empty one fails validation. try: coevera("PATCH", f"/entities/Opportunities/{opportunity_id}", json={"name": ""}) print("The update was accepted.") except ApiError as err: if err.status != 422: raise print("Refused:", err.status, err.body["code"], err.body["name"]) for e in err.body["entity_errors"]: print(f" record: {e['code']} {e['message'].strip()}") for f in err.body["field_errors"]: for e in f["errors"]: print(f" {f['field_name']}: {e['code']} {e['message'].strip()}") ``` **GraphQL · cURL** ```bash # An update the API refuses: name is required, so an empty one fails validation. Each error # carries api_error with the code and the field errors. refused=$(jq -n --arg id "$OPPORTUNITY_ID" '{ query: "mutation Rename($input: UpdateOpportunityInput!) { updateOpportunity(input: $input) { opportunity { id name } } }", variables: { input: { id: $id, name: "" } } }' | gql_raw) echo "$refused" # Messages can start with a newline: trim them. jq -r '(.errors // [])[] | .api_error // empty | (.entity_errors[] | "record: \(.code) \(.message | sub("^\\s+"; ""))"), (.field_errors[] | .field_name as $f | .errors[] | "\($f): \(.code) \(.message | sub("^\\s+"; ""))")' <<<"$refused" ``` **GraphQL · TypeScript** ```ts // An update the API refuses: name is required, so an empty one fails validation. try { await gql( `mutation Rename($input: UpdateOpportunityInput!) { updateOpportunity(input: $input) { opportunity { id name } } }`, { input: { id: opportunityId, name: "" } }, ); console.log("The update was accepted."); } catch (err) { const apiError = err instanceof ApiError ? err.errors[0].api_error : undefined; if (!apiError) throw err; console.log("Refused:", apiError.code, apiError.name); for (const e of apiError.entity_errors) console.log(` record: ${e.code} ${e.message.trim()}`); for (const f of apiError.field_errors) { for (const e of f.errors) console.log(` ${f.field_name}: ${e.code} ${e.message.trim()}`); } } ``` **GraphQL · Python** ```python # An update the API refuses: name is required, so an empty one fails validation. try: gql( """ mutation Rename($input: UpdateOpportunityInput!) { updateOpportunity(input: $input) { opportunity { id name } } } """, {"input": {"id": opportunity_id, "name": ""}}, ) print("The update was accepted.") except ApiError as err: api_error = err.errors[0].get("api_error") if not api_error: raise print("Refused:", api_error["code"], api_error["name"]) for e in api_error["entity_errors"]: print(f" record: {e['code']} {e['message'].strip()}") for f in api_error["field_errors"]: for e in f["errors"]: print(f" {f['field_name']}: {e['code']} {e['message'].strip()}") ``` **Recorded response · REST · 422 Unprocessable Entity** ```json { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\n\n[name] Required field 'Opportunity[0b000000-0000-4000-a000-000000000001].name' is empty.", "entity_id": "0b000000-0000-4000-a000-000000000001", "entity_name": "Opportunity", "entity_errors": [], "field_errors": [ { "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "name", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "[name] Required field 'Opportunity[0b000000-0000-4000-a000-000000000001].name' is empty.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "name" } ] } ], "step_checklist_errors": [], "entity_index": null, "http_status": 422, "success": false } ``` **Recorded response · GraphQL · 200 OK** ```json { "errors": [ { "message": "\n\n[name] Required field 'Opportunity[0b000000-0000-4000-a000-000000000001].name' is empty.", "locations": [ { "line": 2, "column": 7 } ], "path": [ "updateOpportunity" ], "api_error": { "code": 40000, "name": "ERROR_ENTITY_VALIDATION", "message": "\n\n[name] Required field 'Opportunity[0b000000-0000-4000-a000-000000000001].name' is empty.", "entity_id": "0b000000-0000-4000-a000-000000000001", "entity_name": "Opportunity", "entity_errors": [], "field_errors": [ { "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "name", "name": "ERROR_FIELD_VALIDATION", "code": 40002, "errors": [ { "code": 119, "name": "ERROR_PARAMETER_MISSING", "message": "[name] Required field 'Opportunity[0b000000-0000-4000-a000-000000000001].name' is empty.", "field_id": "10000000-0000-4000-a000-000000000001", "field_name": "name" } ] } ], "step_checklist_errors": [], "entity_index": null } } ], "data": { "updateOpportunity": null } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 9. Read it back Read the opportunity with its step, account and contact expanded, and check the values there rather than in the write responses. In REST, `load-only` works on the list call, so the program filters on `id`: a `GET /entities/Opportunities/{id}` with `load-only` answers 400 (`ERROR_PARAMETER_UNKNOWN`). **REST · cURL** ```bash curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ --data-urlencode "filter[id]=$OPPORTUNITY_ID" \ -d expand=step,primary_account,primary_contact \ -d load-only=id,name,closing_date,value,cf_competitor,step.name,primary_account.name,primary_contact.first_name,primary_contact.last_name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts const readBack = await coevera( `/entities/Opportunities?${new URLSearchParams({ "filter[id]": opportunityId, expand: "step,primary_account,primary_contact", "load-only": "id,name,closing_date,value,cf_competitor,step.name,primary_account.name,primary_contact.first_name,primary_contact.last_name", })}`, ); console.log(JSON.stringify(readBack.data[0], null, 2)); ``` **REST · Python** ```python read_back = coevera( "GET", "/entities/Opportunities", params={ "filter[id]": opportunity_id, "expand": "step,primary_account,primary_contact", "load-only": "id,name,closing_date,value,cf_competitor,step.name,primary_account.name," "primary_contact.first_name,primary_contact.last_name", }, ) print(json.dumps(read_back["data"][0], indent=2)) ``` **GraphQL · cURL** ```bash jq -n --arg id "$OPPORTUNITY_ID" '{ query: "query ReadBack($id: ID!) { entities { opportunity { getById(entityId: $id) { id name closingDate value { baseValue valueForeign currencyId } step { name } primaryAccount { name } primaryContact { firstName lastName } customFields(apiNames: [\"cf_competitor\"]) } } } }", variables: { id: $id } }' | gql ``` **GraphQL · TypeScript** ```ts const readBack = await gql( `query ReadBack($id: ID!) { entities { opportunity { getById(entityId: $id) { id name closingDate value { baseValue valueForeign currencyId } step { name } primaryAccount { name } primaryContact { firstName lastName } customFields(apiNames: ["cf_competitor"]) } } } }`, { id: opportunityId }, ); console.log(JSON.stringify(readBack.entities.opportunity.getById, null, 2)); ``` **GraphQL · Python** ```python read_back = gql( """ query ReadBack($id: ID!) { entities { opportunity { getById(entityId: $id) { id name closingDate value { baseValue valueForeign currencyId } step { name } primaryAccount { name } primaryContact { firstName lastName } customFields(apiNames: ["cf_competitor"]) } } } } """, {"id": opportunity_id}, ) print(json.dumps(read_back["entities"]["opportunity"]["getById"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0b000000-0000-4000-a000-000000000001", "step": { "name": "Validate" }, "closing_date": "2027-03-31", "name": "Contoso fleet telemetry rollout", "value": { "base_value": 42000, "currency_id": "04000000-0000-4000-a000-000000000001", "value_foreign": 42000 }, "primary_contact": { "first_name": "Lena", "last_name": "Okafor" }, "primary_account": { "name": "Contoso Freight" }, "cf_competitor": "In-house BI team" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opportunity": { "getById": { "id": "0b000000-0000-4000-a000-000000000001", "name": "Contoso fleet telemetry rollout", "closingDate": "2027-03-31", "value": { "baseValue": 42000, "valueForeign": 42000, "currencyId": "04000000-0000-4000-a000-000000000001" }, "step": { "name": "Validate" }, "primaryAccount": { "name": "Contoso Freight" }, "primaryContact": { "firstName": "Lena", "lastName": "Okafor" }, "customFields": "{\"cfCompetitor\": \"In-house BI team\"}" } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## What to check - Steps 1 to 5 each return one record. An empty `data` (GraphQL `edges`) means the e-mail, the pipeline name or the base currency was not found. - Step 6 answers **201** (GraphQL: 200), and `data.id` (GraphQL `createOpportunity.opportunity.id`) is the opportunity the next steps use. - Step 7 answers 200, and the record carries `cf_competitor` (GraphQL `cfCompetitor`). - Step 8 answers **422** (GraphQL: 200 with `errors` and `"updateOpportunity": null`), with a field error on `name`. - Step 9 returns the name unchanged by step 8, `cf_competitor`, the Validate step, Contoso Freight and Lena Okafor, and `value_foreign` equal to `base_value`. - Run the program twice and you get two opportunities with the same name: the API does not refuse duplicates. To make it safe to repeat, look the opportunity up by `name` and account first. ## Clean up The program leaves the opportunity in your space. Delete it with `POST /entities/Opportunities/batch-delete`, then its rows in `LeadOpptyAccountRelations` and `LeadOpptyContactRelations` (filter on `lead_oppty_id`): deleting an opportunity leaves them active. Deletes are soft: deleted records stay readable with `include-deleted=true`. ## Limits - Each lookup takes the first match. The API does not refuse two records with the same e-mail, so in a space with duplicates, choose the record deliberately. - The program stops at the first unexpected error and does not retry. - It creates one opportunity. For many, send them in one `Opportunities/batch-modify` call with `rollback-method=1` and check every item. --- title: Products and line items description: Create a product category and a product, set its list price, and add it to an opportunity as a line item with a price and a quantity, in REST or GraphQL. canonical: https://developers.coevera.com/recipes/products-and-line-items/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Products and line items **Answer:** Create the category, then the product with name, unit_symbol and a unique sku. Set its price in the opportunity's price list, then add an OpptyProductRelations line with oppty_id, product_id, price and quantity. Never send a discount: discount_value and discount_percentage are computed against the list price, and REST refuses them with 400. Northwind Analytics starts selling a **Data quality audit**. Create it in a new **Analytics services** category, give it a list price of 4500, and add three audits at a partner rate of 4000 to Northwind's open opportunity. You end with a line item whose amount and discount the API computed. ## Before you start | | | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Permissions | **Full Access** to **Products & Prices** on the **Administration** tab, **Full Access** to **Opportunities** and **Read Only Access** to **Accounts** on the **Records** tab. | | `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_API_KEY` and `$COEVERA_API_SECRET`. | A line item (`OpptyProductRelations`, in the app a product on the opportunity) holds the product, the `quantity` and the per-unit `price`. The API computes the rest: `amount`, and `discount_value` and `discount_percentage` against the product's price in the opportunity's price list (`product_price_list_id`). The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Accounts`](/reference/rest/accounts/#list), [`GET /entities/Opportunities`](/reference/rest/opportunities/#list), [`POST /entities/ProductCategories`](/reference/rest/product-categories/#create), [`POST /entities/Products`](/reference/rest/products/#create), [`GET /entities/ProductPriceListPrices`](/reference/rest/product-price-list-prices/#list), [`PATCH /entities/ProductPriceListPrices/{id}`](/reference/rest/product-price-list-prices/#update), [`POST /entities/OpptyProductRelations`](/reference/rest/oppty-product-relations/#create), [`GET /entities/OpptyProductRelations`](/reference/rest/oppty-product-relations/#list); GraphQL [`AccountRepository.getByCriteria`](/reference/graphql/account/#q-getByCriteria), [`OpportunityRepository.getByCriteria`](/reference/graphql/opportunity/#q-getByCriteria), [`createProductCategory`](/reference/graphql/product-category/#m-createProductCategory), [`createProduct`](/reference/graphql/product/#m-createProduct), [`ProductPriceListPriceRepository.getByCriteria`](/reference/graphql/product-price-list-price/#q-getByCriteria), [`updateProductPriceListPrice`](/reference/graphql/product-price-list-price/#m-updateProductPriceListPrice), [`createOpptyProductRelation`](/reference/graphql/oppty-product-relation/#m-createOpptyProductRelation), [`OpptyProductRelationRepository.getByCriteria`](/reference/graphql/oppty-product-relation/#q-getByCriteria). ## Set up a helper Every step below uses this setup. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); 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; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 ``` **GraphQL · cURL** ```bash COEVERA_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" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 account Look the account up by its e-mail address. **REST · cURL** ```bash account=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ --data-urlencode "filter[email1]=northwind@example.com" \ -d first=1 -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$account" ACCOUNT_ID=$(jq -er '.data[0].id' <<<"$account") ``` **REST · TypeScript** ```ts const accounts = await coevera( `/entities/Accounts?${new URLSearchParams({ "filter[email1]": "northwind@example.com", first: "1", "load-only": "id,name" })}`, ); const accountId: string | undefined = accounts.data[0]?.id; if (!accountId) throw new Error("No account with that e-mail."); console.log("Account:", accounts.data[0].name, accountId); ``` **REST · Python** ```python accounts = coevera( "GET", "/entities/Accounts", params={"filter[email1]": "northwind@example.com", "first": 1, "load-only": "id,name"}, ) if not accounts["data"]: sys.exit("No account with that e-mail.") account_id = accounts["data"][0]["id"] print("Account:", accounts["data"][0]["name"], account_id) ``` **GraphQL · cURL** ```bash account=$(jq -n '{ query: "{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: [\"northwind@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$account" ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$account") ``` **GraphQL · TypeScript** ```ts const accounts = await gql(`{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: ["northwind@example.com"] } }) { edges { node { id name } } } } } }`); const account = accounts.entities.account.getByCriteria.edges[0]?.node; if (!account) throw new Error("No account with that e-mail."); console.log("Account:", account.name, account.id); ``` **GraphQL · Python** ```python accounts = gql(""" { entities { account { getByCriteria(first: 1, filter: { email1: { eq: ["northwind@example.com"] } }) { edges { node { id name } } } } } } """) edges = accounts["entities"]["account"]["getByCriteria"]["edges"] if not edges: sys.exit("No account with that e-mail.") account = edges[0]["node"] print("Account:", account["name"], account["id"]) ``` **Recorded response · REST · 200 OK** ```json { "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", "name": "Northwind Analytics" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "account": { "getByCriteria": { "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Northwind Analytics" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 2. Find the opportunity and its price list Find the account's opportunity through its account relations. `product_price_list_id` names the price list its line items take their prices from; the next steps price the product there. **REST · cURL** ```bash # The account's opportunity, with the price list its line items take their prices from. opportunity=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ -d "filter[account_relations.account_id]=$ACCOUNT_ID" \ -d first=1 -d load-only=id,name,product_price_list_id \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$opportunity" OPPORTUNITY_ID=$(jq -er '.data[0].id' <<<"$opportunity") PRICE_LIST_ID=$(jq -er '.data[0].product_price_list_id' <<<"$opportunity") ``` **REST · TypeScript** ```ts // The account's opportunity, with the price list its line items take their prices from. const opportunities = await coevera( `/entities/Opportunities?filter[account_relations.account_id]=${accountId}&first=1&load-only=id,name,product_price_list_id`, ); const opportunity = opportunities.data[0]; if (!opportunity?.product_price_list_id) throw new Error("No opportunity on that account, or it has no price list."); console.log("Opportunity:", opportunity.name, opportunity.id); ``` **REST · Python** ```python # The account's opportunity, with the price list its line items take their prices from. opportunities = coevera( "GET", "/entities/Opportunities", params={ "filter[account_relations.account_id]": account_id, "first": 1, "load-only": "id,name,product_price_list_id", }, ) if not opportunities["data"] or not opportunities["data"][0]["product_price_list_id"]: sys.exit("No opportunity on that account, or it has no price list.") opportunity = opportunities["data"][0] print("Opportunity:", opportunity["name"], opportunity["id"]) ``` **GraphQL · cURL** ```bash # The account's opportunity, with the price list its line items take their prices from. opportunity=$(jq -n --arg account "$ACCOUNT_ID" '{ query: "query FindOpportunity($account: String!) { entities { opportunity { getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$account] } } }) { edges { node { id name productPriceListId } } } } } }", variables: { account: $account } }' | gql) echo "$opportunity" OPPORTUNITY_ID=$(jq -er '.data.entities.opportunity.getByCriteria.edges[0].node.id' <<<"$opportunity") PRICE_LIST_ID=$(jq -er '.data.entities.opportunity.getByCriteria.edges[0].node.productPriceListId' <<<"$opportunity") ``` **GraphQL · TypeScript** ```ts // The account's opportunity, with the price list its line items take their prices from. const opportunities = await gql( `query FindOpportunity($accountId: String!) { entities { opportunity { getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$accountId] } } }) { edges { node { id name productPriceListId } } } } } }`, { accountId: account.id }, ); const opportunity = opportunities.entities.opportunity.getByCriteria.edges[0]?.node; if (!opportunity?.productPriceListId) throw new Error("No opportunity on that account, or it has no price list."); console.log("Opportunity:", opportunity.name, opportunity.id); ``` **GraphQL · Python** ```python # The account's opportunity, with the price list its line items take their prices from. opportunities = gql( """ query FindOpportunity($accountId: String!) { entities { opportunity { getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$accountId] } } }) { edges { node { id name productPriceListId } } } } } } """, {"accountId": account["id"]}, ) edges = opportunities["entities"]["opportunity"]["getByCriteria"]["edges"] if not edges or not edges[0]["node"]["productPriceListId"]: sys.exit("No opportunity on that account, or it has no price list.") opportunity = edges[0]["node"] print("Opportunity:", opportunity["name"], opportunity["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0b000000-0000-4000-a000-000000000001", "name": "Northwind data platform rollout", "product_price_list_id": "02000000-0000-4000-a000-000000000001" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opportunity": { "getByCriteria": { "edges": [ { "node": { "id": "0b000000-0000-4000-a000-000000000001", "name": "Northwind data platform rollout", "productPriceListId": "02000000-0000-4000-a000-000000000001" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 3. Create the category A category groups products in the catalogue. Only `name` is needed. The response carries the `id` the product uses. **REST · cURL** ```bash category=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/ProductCategories" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "name": "Analytics services" }') echo "$category" CATEGORY_ID=$(jq -er '.data.id' <<<"$category") ``` **REST · TypeScript** ```ts const category = await coevera("/entities/ProductCategories", { method: "POST", body: { name: "Analytics services" }, }); const categoryId: string = category.data.id; console.log("Category:", category.data.name, categoryId); ``` **REST · Python** ```python category = coevera("POST", "/entities/ProductCategories", json={"name": "Analytics services"}) category_id = category["data"]["id"] print("Category:", category["data"]["name"], category_id) ``` **GraphQL · cURL** ```bash category=$(jq -n '{ query: "mutation CreateCategory($input: CreateProductCategoryInput!) { createProductCategory(input: $input) { productCategory { id name } } }", variables: { input: { name: "Analytics services" } } }' | gql) echo "$category" CATEGORY_ID=$(jq -er '.data.createProductCategory.productCategory.id' <<<"$category") ``` **GraphQL · TypeScript** ```ts const category = await gql( `mutation CreateCategory($input: CreateProductCategoryInput!) { createProductCategory(input: $input) { productCategory { id name } } }`, { input: { name: "Analytics services" } }, ); const categoryId: string = category.createProductCategory.productCategory.id; console.log("Category:", category.createProductCategory.productCategory.name, categoryId); ``` **GraphQL · Python** ```python category = gql( """ mutation CreateCategory($input: CreateProductCategoryInput!) { createProductCategory(input: $input) { productCategory { id name } } } """, {"input": {"name": "Analytics services"}}, )["createProductCategory"]["productCategory"] print("Category:", category["name"], category["id"]) ``` **Recorded response · REST · 201 Created** ```json { "success": true, "data": { "is_delete_protected": false, "id": "11000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.000000+00:00", "created": "2026-10-15 09:00:00.000031+00:00", "parent": null, "name": "Analytics services", "parent_id": null, "revision": 774, "product_relations": [] } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createProductCategory": { "productCategory": { "id": "11000000-0000-4000-a000-000000000001", "name": "Analytics services" } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 4. Create the product `name` and `unit_symbol` are required: without `unit_symbol` the API answers 422, although the published spec marks only `name` as required ([Products reference](/reference/rest/products/#create)). `sku` must be unique in the space: a second product with the same `sku` is refused with 400 (`Column sku must be unique.`). `allowed_pipelines` defaults to `1`, all pipelines. **REST · cURL** ```bash # name and unit_symbol are required; sku must be unique in the space. product=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Products" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- </dev/null echo "List price set to 4500." ``` **REST · TypeScript** ```ts await coevera(`/entities/ProductPriceListPrices/${priceId}`, { method: "PATCH", body: { price: 4500 } }); console.log("List price set to 4500."); ``` **REST · Python** ```python coevera("PATCH", f"/entities/ProductPriceListPrices/{price_id}", json={"price": 4500}) print("List price set to 4500.") ``` **GraphQL · cURL** ```bash jq -n --arg id "$PRICE_ID" '{ query: "mutation SetPrice($input: UpdateProductPriceListPriceInput!) { updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } } }", variables: { input: { id: $id, price: 4500 } } }' | gql ``` **GraphQL · TypeScript** ```ts await gql( `mutation SetPrice($input: UpdateProductPriceListPriceInput!) { updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } } }`, { input: { id: priceRow.id, price: 4500 } }, ); console.log("List price set to 4500."); ``` **GraphQL · Python** ```python gql( """ mutation SetPrice($input: UpdateProductPriceListPriceInput!) { updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } } } """, {"input": {"id": price_row["id"], "price": 4500}}, ) print("List price set to 4500.") ``` **Recorded response · REST · 200 OK** ```json { "success": true, "data": { "is_delete_protected": false, "id": "13000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.538771+00:00", "created": "2026-10-15 09:00:00.192015+00:00", "currency": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Currencies/04000000-0000-4000-a000-000000000001", "product": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Products/09000000-0000-4000-a000-000000000001", "product_price_list": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ProductPriceLists/02000000-0000-4000-a000-000000000001", "currency_id": "04000000-0000-4000-a000-000000000001", "price": 4500, "product_id": "09000000-0000-4000-a000-000000000001", "product_price_list_id": "02000000-0000-4000-a000-000000000001", "settings": { "access": "All", "access_roles": null }, "revision": 776 } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "updateProductPriceListPrice": { "productPriceListPrice": { "id": "13000000-0000-4000-a000-000000000001", "price": 4500 } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 7. Add the line item Send `oppty_id`, `product_id`, `quantity` and `price`. Always send `quantity`: without it, the line gets a quantity of 1. Without `price`, the line takes the product's price in the opportunity's price list, or 0 when the opportunity has none. **REST · cURL** ```bash # Send price and quantity. The amount and the discount are computed; the API refuses them. curl -sS --fail-with-body "$COEVERA_BASE/entities/OpptyProductRelations" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- < **Warning:** Never send a discount. `discount_value` and `discount_percentage` are read-only and computed: a > REST create with either one is refused with 400 (`ERROR_PARAMETER_INVALID`), and the GraphQL > input has no such fields. Set the `price` you agreed instead. ## 8. Read it back Read the line item back with its product. The create response above still shows an `amount` of 0; the read-back shows the computed values. **REST · cURL** ```bash curl -sS --fail-with-body -G "$COEVERA_BASE/entities/OpptyProductRelations" \ -d "filter[oppty_id]=$OPPORTUNITY_ID" \ -d "filter[product_id]=$PRODUCT_ID" \ -d expand=product \ -d load-only=id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts const lines = await coevera( `/entities/OpptyProductRelations?${new URLSearchParams({ "filter[oppty_id]": opportunity.id, "filter[product_id]": productId, expand: "product", "load-only": "id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku", })}`, ); console.log(JSON.stringify(lines.data, null, 2)); ``` **REST · Python** ```python lines = coevera( "GET", "/entities/OpptyProductRelations", params={ "filter[oppty_id]": opportunity["id"], "filter[product_id]": product_id, "expand": "product", "load-only": "id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku", }, ) print(json.dumps(lines["data"], indent=2)) ``` **GraphQL · cURL** ```bash jq -n --arg oppty "$OPPORTUNITY_ID" --arg product "$PRODUCT_ID" '{ query: "query ReadBack($oppty: String!, $product: String!) { entities { opptyProductRelation { getByCriteria(filter: { opptyId: { eq: [$oppty] }, productId: { eq: [$product] } }) { edges { node { id quantity price amount discountValue discountPercentage product { name sku productCategory { name } } } } } } } }", variables: { oppty: $oppty, product: $product } }' | gql ``` **GraphQL · TypeScript** ```ts const lines = await gql( `query ReadBack($opportunityId: String!, $productId: String!) { entities { opptyProductRelation { getByCriteria(filter: { opptyId: { eq: [$opportunityId] }, productId: { eq: [$productId] } }) { edges { node { id quantity price amount discountValue discountPercentage product { name sku productCategory { name } } } } } } } }`, { opportunityId: opportunity.id, productId }, ); console.log(JSON.stringify(lines.entities.opptyProductRelation.getByCriteria.edges, null, 2)); ``` **GraphQL · Python** ```python lines = gql( """ query ReadBack($opportunityId: String!, $productId: String!) { entities { opptyProductRelation { getByCriteria(filter: { opptyId: { eq: [$opportunityId] }, productId: { eq: [$productId] } }) { edges { node { id quantity price amount discountValue discountPercentage product { name sku productCategory { name } } } } } } } } """, {"opportunityId": opportunity["id"], "productId": product["id"]}, ) print(json.dumps(lines["entities"]["opptyProductRelation"]["getByCriteria"]["edges"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIxMjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIxMjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "12000000-0000-4000-a000-000000000001", "product": { "name": "Data quality audit", "sku": "NW-DQ-AUDIT" }, "amount": 12000, "price": 4000, "quantity": 3, "discount_value": -1500, "discount_percentage": 11.11111111111111 } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opptyProductRelation": { "getByCriteria": { "edges": [ { "node": { "id": "12000000-0000-4000-a000-000000000001", "quantity": 3, "price": 4000, "amount": 12000, "discountValue": -1500, "discountPercentage": 11.11111111111111, "product": { "name": "Data quality audit", "sku": "NW-DQ-AUDIT", "productCategory": { "name": "Analytics services" } } } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## What to check - Step 2 returns `product_price_list_id`. Without one, a line item sent without a `price` gets a price of 0. - Steps 3 and 4 answer **201** (GraphQL: 200), and step 5 finds one price row of 0 for the new product. - Step 8 returns `quantity: 3`, `price: 4000` and `amount: 12000`, with `discount_percentage` 11.11 and `discount_value` -1500: the API reports a price below the list price as a negative `discount_value`. - Run the program twice and the second run stops at step 4: the `sku` is taken. To make it safe to repeat, look the product up by `sku` first and reuse it. ## Clean up The program leaves the category, the product and the line item in your space. Delete the line item (`POST /entities/OpptyProductRelations/batch-delete`) before the product: deleting a product leaves its line items active. Then delete the product, whose price rows go with it, and the category, each with its own `batch-delete`. Deletes are soft, and a deleted product's `sku` can be used again. ## Limits - The program adds one line item. Several lines go in one call with `POST /entities/OpptyProductRelations/batch-modify` and `rollback-method=1`. - It does not set the opportunity's `value`. When the opportunity's `is_value_auto_calculate` is `true`, adding, changing or deleting a line item sets `value` to the sum of the line items' `amount`; with `false`, the default for an opportunity created through the API, line items leave `value` alone. - It prices the product in the opportunity's price list only. --- title: Attach a document description: Upload a file as base64 and attach it to an account, in two calls through CloudObjects or in one through CloudObjectRelations, in REST or GraphQL. canonical: https://developers.coevera.com/recipes/attach-document/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Attach a document **Answer:** Send the file as base64 content with a filename and type 1 (S3File). Create it in CloudObjects and link it with a CloudObjectRelations row, or nest it in the relation create to do both in one call. The limit is the space's Attachment Size Limit, 30 MB by default and up to 200 MB, which the API cannot read. Attach two files to **Fabrikam Robotics**: a pilot scope, uploaded first and linked second, and a site survey, uploaded and linked in one call. You end with both files on the account, read back with the size and type the API recorded. ## Before you start | | | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Permissions | **Full Access** to **Documents** on the **Misc** tab, and **Read Only Access** to **Accounts** on the **Records** tab. | | `validation-level` | The default (`0`, full validation). | | Batch behaviour | No batches: every write is one record. | | File size | The space's **Attachment Size Limit**: 30 MB by default, and an admin can raise it up to 200 MB. The API does not expose the value. Base64 adds about a third, so a 30 MB file is about 40 MB of JSON. | | Context | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`. | A file is a `CloudObjects` record (in the app, a document). It appears on a record through a `CloudObjectRelations` row that names the file and the record: `account_id` here, or `contact_id`, `lead_oppty_id`, `project_id`, `quote_id`, `custom_entity_id` and the other parent ids the relation takes. The programs need `curl`, `jq` and `base64`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Accounts`](/reference/rest/accounts/#list), [`POST /entities/CloudObjects`](/reference/rest/cloud-objects/#create), [`POST /entities/CloudObjectRelations`](/reference/rest/cloud-object-relations/#create), [`GET /entities/CloudObjectRelations`](/reference/rest/cloud-object-relations/#list); GraphQL [`AccountRepository.getByCriteria`](/reference/graphql/account/#q-getByCriteria), [`createCloudObject`](/reference/graphql/cloud-object/#m-createCloudObject), [`createCloudObjectRelation`](/reference/graphql/cloud-object-relation/#m-createCloudObjectRelation), [`CloudObjectRelationRepository.getByCriteria`](/reference/graphql/cloud-object-relation/#q-getByCriteria). ## Set up a helper Every step below uses this setup, and a helper that base64-encodes the file. The programs attach short texts; in your integration, encode the file's bytes. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # b64: the file goes up as base64 in JSON, on one line. In your integration, encode the file # itself: base64 < pilot-scope.pdf | tr -d '\n' b64() { printf '%s\n' "$1" | base64 | tr -d '\n'; } ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); 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; } // The file goes up as base64 in JSON. Here it is a short text; in your integration, read the // file's bytes, for example with fs.readFile. const base64 = (text: string) => Buffer.from(text, "utf8").toString("base64"); ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 b64(text): """The file goes up as base64 in JSON. In your integration, read the file's bytes instead.""" return base64.b64encode(text.encode("utf-8")).decode("ascii") ``` **GraphQL · cURL** ```bash COEVERA_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" } # b64: the file goes up as base64, on one line. In your integration, encode the file itself: # base64 < pilot-scope.pdf | tr -d '\n' b64() { printf '%s\n' "$1" | base64 | tr -d '\n'; } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } // The file goes up as base64 (the Base64 scalar). Here it is a short text; in your // integration, read the file's bytes, for example with fs.readFile. const base64 = (text: string) => Buffer.from(text, "utf8").toString("base64"); ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 b64(text): """The file goes up as base64 (the Base64 scalar). In your integration, read the file's bytes.""" return base64.b64encode(text.encode("utf-8")).decode("ascii") ``` ## 1. Find the account Look the account up by its e-mail address. **REST · cURL** ```bash account=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \ --data-urlencode "filter[email1]=fabrikam@example.com" \ -d first=1 -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$account" ACCOUNT_ID=$(jq -er '.data[0].id' <<<"$account") ``` **REST · TypeScript** ```ts const accounts = await coevera( `/entities/Accounts?${new URLSearchParams({ "filter[email1]": "fabrikam@example.com", first: "1", "load-only": "id,name" })}`, ); const accountId: string | undefined = accounts.data[0]?.id; if (!accountId) throw new Error("No account with that e-mail."); console.log("Account:", accounts.data[0].name, accountId); ``` **REST · Python** ```python accounts = coevera( "GET", "/entities/Accounts", params={"filter[email1]": "fabrikam@example.com", "first": 1, "load-only": "id,name"}, ) if not accounts["data"]: sys.exit("No account with that e-mail.") account_id = accounts["data"][0]["id"] print("Account:", accounts["data"][0]["name"], account_id) ``` **GraphQL · cURL** ```bash account=$(jq -n '{ query: "{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: [\"fabrikam@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$account" ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$account") ``` **GraphQL · TypeScript** ```ts const accounts = await gql(`{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: ["fabrikam@example.com"] } }) { edges { node { id name } } } } } }`); const account = accounts.entities.account.getByCriteria.edges[0]?.node; if (!account) throw new Error("No account with that e-mail."); console.log("Account:", account.name, account.id); ``` **GraphQL · Python** ```python accounts = gql(""" { entities { account { getByCriteria(first: 1, filter: { email1: { eq: ["fabrikam@example.com"] } }) { edges { node { id name } } } } } } """) edges = accounts["entities"]["account"]["getByCriteria"]["edges"] if not edges: sys.exit("No account with that e-mail.") account = edges[0]["node"] print("Account:", account["name"], account["id"]) ``` **Recorded response · REST · 200 OK** ```json { "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", "name": "Fabrikam Robotics" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "account": { "getByCriteria": { "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Fabrikam Robotics" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 2. Upload a file Create the file in `CloudObjects` with `filename`, `type` and `content`, the file's bytes in base64. The API stores it, measures its `size` and detects its `mime_type`. Its `url` is a signed download link that expires after about 6 hours. The recording shows it redacted. **REST · cURL** ```bash # type 1 (S3File) stores the content in Coevera's file storage. file=$(jq -n --arg content "$(b64 'Fabrikam Robotics pilot scope: telemetry from 12 assembly lines, 6 weeks.')" \ '{ filename: "pilot-scope.txt", type: 1, content: $content }' | curl -sS --fail-with-body "$COEVERA_BASE/entities/CloudObjects" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @-) echo "$file" FILE_ID=$(jq -er '.data.id' <<<"$file") ``` **REST · TypeScript** ```ts // type 1 (S3File) stores the content in Coevera's file storage. const uploaded = await coevera("/entities/CloudObjects", { method: "POST", body: { filename: "pilot-scope.txt", type: 1, content: base64("Fabrikam Robotics pilot scope: telemetry from 12 assembly lines, 6 weeks.\n"), }, }); const fileId: string = uploaded.data.id; console.log("File:", uploaded.data.filename, fileId); ``` **REST · Python** ```python # type 1 (S3File) stores the content in Coevera's file storage. uploaded = coevera( "POST", "/entities/CloudObjects", json={ "filename": "pilot-scope.txt", "type": 1, "content": b64("Fabrikam Robotics pilot scope: telemetry from 12 assembly lines, 6 weeks.\n"), }, ) file_id = uploaded["data"]["id"] print("File:", uploaded["data"]["filename"], file_id) ``` **GraphQL · cURL** ```bash # S3File stores the content in Coevera's file storage. file=$(jq -n --arg content "$(b64 'Fabrikam Robotics pilot scope: telemetry from 12 assembly lines, 6 weeks.')" '{ query: "mutation UploadFile($input: CreateCloudObjectInput!) { createCloudObject(input: $input) { cloudObject { id filename size mimeType } } }", variables: { input: { filename: "pilot-scope.txt", type: "S3File", content: $content } } }' | gql) echo "$file" FILE_ID=$(jq -er '.data.createCloudObject.cloudObject.id' <<<"$file") ``` **GraphQL · TypeScript** ```ts // S3File stores the content in Coevera's file storage. const uploaded = await gql( `mutation UploadFile($input: CreateCloudObjectInput!) { createCloudObject(input: $input) { cloudObject { id filename size mimeType } } }`, { input: { filename: "pilot-scope.txt", type: "S3File", content: base64("Fabrikam Robotics pilot scope: telemetry from 12 assembly lines, 6 weeks.\n"), }, }, ); const fileId: string = uploaded.createCloudObject.cloudObject.id; console.log("File:", uploaded.createCloudObject.cloudObject.filename, fileId); ``` **GraphQL · Python** ```python # S3File stores the content in Coevera's file storage. uploaded = gql( """ mutation UploadFile($input: CreateCloudObjectInput!) { createCloudObject(input: $input) { cloudObject { id filename size mimeType } } } """, { "input": { "filename": "pilot-scope.txt", "type": "S3File", "content": b64("Fabrikam Robotics pilot scope: telemetry from 12 assembly lines, 6 weeks.\n"), } }, )["createCloudObject"]["cloudObject"] file_id = uploaded["id"] print("File:", uploaded["filename"], file_id) ``` **Recorded response · REST · 201 Created** ```json { "success": true, "data": { "is_delete_protected": false, "id": "16000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.000000+00:00", "created": "2026-10-15 09:00:00.000041+00:00", "application": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Applications/18000000-0000-4000-a000-000000000001", "company_cloud_object_folder": null, "creator": null, "application_id": "18000000-0000-4000-a000-000000000001", "company_cloud_object_folder_id": null, "creator_id": null, "filename": "pilot-scope.txt", "is_public": false, "mime_type": "text/plain", "params": {}, "size": 74, "type": 1, "upload_source": null, "url": "https://storage.example.com/files/redacted.txt?Expires=redacted&Signature=redacted", "revision": 880, "dimensions": null, "duration": null, "public_url": null } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createCloudObject": { "cloudObject": { "id": "16000000-0000-4000-a000-000000000001", "filename": "pilot-scope.txt", "size": 74, "mimeType": "text/plain" } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ `type` says where the file lives. Use `1` (GraphQL `S3File`) to upload content: | `type` | GraphQL | What it is | | ---------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `-1` | `S3FileUpload` | A large-file upload through a pre-signed URL, which the public API does not issue: without a `url`, the create answers 422. | | `1` | `S3File` | A file stored in Coevera, sent as base64 `content`. | | `2` | `S3Image` | An image stored in Coevera. | | `3` to `7` | `GoogleDriveFile`, `OneDriveFile`, `BoxFile`, `DropboxFile`, `SharepointFile` | A file in a connected cloud drive. | | `8` | `ExternalURL` | A link to an external URL. | | `9` | `AlfrescoFile` | A file in Alfresco. | | `10` | `UrlFile` | A file referenced by URL. | ## 3. Link it to the account A file on its own belongs to no record. Create a `CloudObjectRelations` row with its `cloud_object_id` and the `account_id`. **REST · cURL** ```bash curl -sS --fail-with-body "$COEVERA_BASE/entities/CloudObjectRelations" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d "{ \"cloud_object_id\": \"$FILE_ID\", \"account_id\": \"$ACCOUNT_ID\" }" >/dev/null echo "Linked to the account." ``` **REST · TypeScript** ```ts await coevera("/entities/CloudObjectRelations", { method: "POST", body: { cloud_object_id: fileId, account_id: accountId }, }); console.log("Linked to the account."); ``` **REST · Python** ```python coevera("POST", "/entities/CloudObjectRelations", json={"cloud_object_id": file_id, "account_id": account_id}) print("Linked to the account.") ``` **GraphQL · cURL** ```bash jq -n --arg file "$FILE_ID" --arg account "$ACCOUNT_ID" '{ query: "mutation LinkFile($input: CreateCloudObjectRelationInput!) { createCloudObjectRelation(input: $input) { cloudObjectRelation { id } } }", variables: { input: { cloudObjectId: $file, accountId: $account } } }' | gql ``` **GraphQL · TypeScript** ```ts await gql( `mutation LinkFile($input: CreateCloudObjectRelationInput!) { createCloudObjectRelation(input: $input) { cloudObjectRelation { id } } }`, { input: { cloudObjectId: fileId, accountId: account.id } }, ); console.log("Linked to the account."); ``` **GraphQL · Python** ```python gql( """ mutation LinkFile($input: CreateCloudObjectRelationInput!) { createCloudObjectRelation(input: $input) { cloudObjectRelation { id } } } """, {"input": {"cloudObjectId": file_id, "accountId": account["id"]}}, ) print("Linked to the account.") ``` **Recorded response · REST · 201 Created** ```json { "success": true, "data": { "is_delete_protected": false, "id": "17000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.368091+00:00", "created": "2026-10-15 09:00:00.368141+00:00", "account": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Accounts/0a000000-0000-4000-a000-000000000001", "appointment": null, "task": null, "approval_process": null, "call": null, "cloud_object_folder": null, "cloud_object": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/CloudObjects/16000000-0000-4000-a000-000000000001", "contact": null, "custom_entity": null, "email_sequence": null, "email_template": null, "field_mapping": null, "lead": null, "oppty": null, "media": null, "email": null, "memo": null, "online_form_field": null, "online_form": null, "process": null, "process_template": null, "project": null, "quote": null, "report_schedule": null, "text_message": null, "account_id": "0a000000-0000-4000-a000-000000000001", "activity_id": null, "approval_process_id": null, "call_id": null, "cloud_object_folder_id": null, "cloud_object_id": "16000000-0000-4000-a000-000000000001", "contact_id": null, "custom_entity_id": null, "email_sequence_id": null, "email_template_id": null, "field_mapping_id": null, "lead_oppty_id": null, "media_id": null, "message_id": null, "online_form_field_id": null, "online_form_id": null, "process_id": null, "process_template_id": null, "project_id": null, "quote_id": null, "report_schedule_id": null, "text_message_id": null, "revision": 881 } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createCloudObjectRelation": { "cloudObjectRelation": { "id": "17000000-0000-4000-a000-000000000001" } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 4. Upload and link in one call Nest the file in `cloud_object` (GraphQL `cloudObject`) of the relation create: the API stores the file and links it in one call, and returns the new file's id in `cloud_object_id`. Use the two-call path of steps 2 and 3 when one file goes on several records. **REST · cURL** ```bash # The file nested in the relation: uploaded and linked in one call. attached=$(jq -n --arg account "$ACCOUNT_ID" \ --arg content "$(b64 'Fabrikam Robotics site survey: Vancouver plant, 2026-11-03.')" \ '{ account_id: $account, cloud_object: { filename: "site-survey.txt", type: 1, content: $content } }' | curl -sS --fail-with-body "$COEVERA_BASE/entities/CloudObjectRelations" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @-) echo "$attached" SECOND_FILE_ID=$(jq -er '.data.cloud_object_id' <<<"$attached") ``` **REST · TypeScript** ```ts // The file nested in the relation: uploaded and linked in one call. const attached = await coevera("/entities/CloudObjectRelations", { method: "POST", body: { account_id: accountId, cloud_object: { filename: "site-survey.txt", type: 1, content: base64("Fabrikam Robotics site survey: Vancouver plant, 2026-11-03.\n"), }, }, }); const secondFileId: string = attached.data.cloud_object_id; console.log("Second file:", secondFileId); ``` **REST · Python** ```python # The file nested in the relation: uploaded and linked in one call. attached = coevera( "POST", "/entities/CloudObjectRelations", json={ "account_id": account_id, "cloud_object": { "filename": "site-survey.txt", "type": 1, "content": b64("Fabrikam Robotics site survey: Vancouver plant, 2026-11-03.\n"), }, }, ) second_file_id = attached["data"]["cloud_object_id"] print("Second file:", second_file_id) ``` **GraphQL · cURL** ```bash # The file nested in the relation: uploaded and linked in one call. attached=$(jq -n --arg account "$ACCOUNT_ID" \ --arg content "$(b64 'Fabrikam Robotics site survey: Vancouver plant, 2026-11-03.')" '{ query: "mutation AttachFile($input: CreateCloudObjectRelationInput!) { createCloudObjectRelation(input: $input) { cloudObjectRelation { id cloudObject { id filename } } } }", variables: { input: { accountId: $account, cloudObject: { filename: "site-survey.txt", type: "S3File", content: $content } } } }' | gql) echo "$attached" SECOND_FILE_ID=$(jq -er '.data.createCloudObjectRelation.cloudObjectRelation.cloudObject.id' <<<"$attached") ``` **GraphQL · TypeScript** ```ts // The file nested in the relation: uploaded and linked in one call. const attached = await gql( `mutation AttachFile($input: CreateCloudObjectRelationInput!) { createCloudObjectRelation(input: $input) { cloudObjectRelation { id cloudObject { id filename } } } }`, { input: { accountId: account.id, cloudObject: { filename: "site-survey.txt", type: "S3File", content: base64("Fabrikam Robotics site survey: Vancouver plant, 2026-11-03.\n"), }, }, }, ); const secondFileId: string = attached.createCloudObjectRelation.cloudObjectRelation.cloudObject.id; console.log("Second file:", secondFileId); ``` **GraphQL · Python** ```python # The file nested in the relation: uploaded and linked in one call. attached = gql( """ mutation AttachFile($input: CreateCloudObjectRelationInput!) { createCloudObjectRelation(input: $input) { cloudObjectRelation { id cloudObject { id filename } } } } """, { "input": { "accountId": account["id"], "cloudObject": { "filename": "site-survey.txt", "type": "S3File", "content": b64("Fabrikam Robotics site survey: Vancouver plant, 2026-11-03.\n"), }, } }, ) second_file_id = attached["createCloudObjectRelation"]["cloudObjectRelation"]["cloudObject"]["id"] print("Second file:", second_file_id) ``` **Recorded response · REST · 201 Created** ```json { "success": true, "data": { "is_delete_protected": false, "id": "17000000-0000-4000-a000-000000000002", "is_deleted": false, "modified": "2026-10-15 09:00:02.066531+00:00", "created": "2026-10-15 09:00:02.066601+00:00", "account": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Accounts/0a000000-0000-4000-a000-000000000001", "appointment": null, "task": null, "approval_process": null, "call": null, "cloud_object_folder": null, "cloud_object": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/CloudObjects/16000000-0000-4000-a000-000000000002", "contact": null, "custom_entity": null, "email_sequence": null, "email_template": null, "field_mapping": null, "lead": null, "oppty": null, "media": null, "email": null, "memo": null, "online_form_field": null, "online_form": null, "process": null, "process_template": null, "project": null, "quote": null, "report_schedule": null, "text_message": null, "account_id": "0a000000-0000-4000-a000-000000000001", "activity_id": null, "approval_process_id": null, "call_id": null, "cloud_object_folder_id": null, "cloud_object_id": "16000000-0000-4000-a000-000000000002", "contact_id": null, "custom_entity_id": null, "email_sequence_id": null, "email_template_id": null, "field_mapping_id": null, "lead_oppty_id": null, "media_id": null, "message_id": null, "online_form_field_id": null, "online_form_id": null, "process_id": null, "process_template_id": null, "project_id": null, "quote_id": null, "report_schedule_id": null, "text_message_id": null, "revision": 882 } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createCloudObjectRelation": { "cloudObjectRelation": { "id": "17000000-0000-4000-a000-000000000002", "cloudObject": { "id": "16000000-0000-4000-a000-000000000002", "filename": "site-survey.txt" } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 5. Read them back Read the account's relations for the two files, with the files expanded. In REST, a raw comma between the two ids in `filter[cloud_object_id]` is an OR; an encoded `%2C` would be a literal comma and match nothing; see [Querying](/concepts/querying/#filter). **REST · cURL** ```bash # A raw comma separates the two ids: an OR filter. curl -sS --fail-with-body -G "$COEVERA_BASE/entities/CloudObjectRelations" \ -d "filter[account_id]=$ACCOUNT_ID" \ -d "filter[cloud_object_id]=$FILE_ID,$SECOND_FILE_ID" \ -d expand=cloud_object \ -d load-only=id,cloud_object.filename,cloud_object.size,cloud_object.mime_type,cloud_object.type \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts // A raw comma separates the two ids: an OR filter. const documents = await coevera( `/entities/CloudObjectRelations?filter[account_id]=${accountId}&filter[cloud_object_id]=${fileId},${secondFileId}` + "&expand=cloud_object&load-only=id,cloud_object.filename,cloud_object.size,cloud_object.mime_type,cloud_object.type", ); console.log(documents.total, "documents:", JSON.stringify(documents.data, null, 2)); ``` **REST · Python** ```python # requests would send the comma as %2C, a literal comma: build the query string by hand so the # raw comma separates the two ids (an OR filter). documents = coevera( "GET", f"/entities/CloudObjectRelations?filter[account_id]={account_id}" f"&filter[cloud_object_id]={file_id},{second_file_id}" "&expand=cloud_object&load-only=id,cloud_object.filename,cloud_object.size,cloud_object.mime_type,cloud_object.type", ) print(documents.get("total"), "documents:", json.dumps(documents["data"], indent=2)) ``` **GraphQL · cURL** ```bash jq -n --arg account "$ACCOUNT_ID" --arg first "$FILE_ID" --arg second "$SECOND_FILE_ID" '{ query: "query ReadBack($account: String!, $files: [String]!) { entities { cloudObjectRelation { getByCriteria(filter: { accountId: { eq: [$account] }, cloudObjectId: { eq: $files } }) { totalCount edges { node { id cloudObject { filename size mimeType type } } } } } } }", variables: { account: $account, files: [$first, $second] } }' | gql ``` **GraphQL · TypeScript** ```ts const documents = await gql( `query ReadBack($accountId: String!, $fileIds: [String]!) { entities { cloudObjectRelation { getByCriteria(filter: { accountId: { eq: [$accountId] }, cloudObjectId: { eq: $fileIds } }) { totalCount edges { node { id cloudObject { filename size mimeType type } } } } } } }`, { accountId: account.id, fileIds: [fileId, secondFileId] }, ); const relations = documents.entities.cloudObjectRelation.getByCriteria; console.log(relations.totalCount, "documents:", JSON.stringify(relations.edges, null, 2)); ``` **GraphQL · Python** ```python documents = gql( """ query ReadBack($accountId: String!, $fileIds: [String]!) { entities { cloudObjectRelation { getByCriteria(filter: { accountId: { eq: [$accountId] }, cloudObjectId: { eq: $fileIds } }) { totalCount edges { node { id cloudObject { filename size mimeType type } } } } } } } """, {"accountId": account["id"], "fileIds": [file_id, second_file_id]}, ) relations = documents["entities"]["cloudObjectRelation"]["getByCriteria"] print(relations["totalCount"], "documents:", json.dumps(relations["edges"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 2, "page_info": { "start_cursor": "WyIxNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIxNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "17000000-0000-4000-a000-000000000001", "cloud_object": { "filename": "pilot-scope.txt", "mime_type": "text/plain", "size": 74, "type": 1 } }, { "id": "17000000-0000-4000-a000-000000000002", "cloud_object": { "filename": "site-survey.txt", "mime_type": "text/plain", "size": 60, "type": 1 } } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "cloudObjectRelation": { "getByCriteria": { "totalCount": 2, "edges": [ { "node": { "id": "17000000-0000-4000-a000-000000000001", "cloudObject": { "filename": "pilot-scope.txt", "size": 74, "mimeType": "text/plain", "type": "S3File" } } }, { "node": { "id": "17000000-0000-4000-a000-000000000002", "cloudObject": { "filename": "site-survey.txt", "size": 60, "mimeType": "text/plain", "type": "S3File" } } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## What to check - Steps 2 to 4 answer **201** (GraphQL: 200). - Step 2 returns the `size` in bytes of what you sent and the detected `mime_type`. - Step 5 returns `total: 2` (GraphQL `totalCount: 2`), with each file's `filename`, `size` and `type`. - Run the program twice and the account has four documents: the API does not refuse two files with the same name. To make it safe to repeat, list the account's documents by `filename` first. ## Clean up The program leaves two files and two relations in your space. Delete the relations with `POST /entities/CloudObjectRelations/batch-delete`, then the files with `POST /entities/CloudObjects/batch-delete`: deleting a relation leaves its file in place. Deletes are soft. ## Limits - The largest file is the space's Attachment Size Limit, which the API cannot read. A larger upload is refused with HTTP 400, code 117. - The content travels base64-encoded in one JSON request; the public API has no chunked or resumable upload. - The program does not download files. A file's `url` is a signed link that expires. --- title: Merge duplicates description: 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. canonical: https://developers.coevera.com/recipes/merge-duplicates/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Merge duplicates **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_API_KEY` and `$COEVERA_API_SECRET`. | 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](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Clients`](/reference/rest/clients/#list), [`POST /entities/Accounts`](/reference/rest/accounts/#create), [`POST /entities/Contacts`](/reference/rest/contacts/#create), [`POST /entities/Accounts/merge`](/reference/rest/accounts/#merge), [`GET /entities/Accounts`](/reference/rest/accounts/#list), [`GET /entities/ContactAccountRelations`](/reference/rest/contact-account-relations/#list); GraphQL [`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria), [`createAccount`](/reference/graphql/account/#m-createAccount), [`createContact`](/reference/graphql/contact/#m-createContact), [`mergeAccount`](/reference/graphql/account/#m-mergeAccount), [`AccountRepository.getById`](/reference/graphql/account/#q-getById), [`AccountRepository.getByCriteria`](/reference/graphql/account/#q-getByCriteria), [`ContactAccountRelationRepository.getByCriteria`](/reference/graphql/contact-account-relation/#q-getByCriteria). ## Set up a helper Every step below uses this setup. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); 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; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 ``` **GraphQL · cURL** ```bash COEVERA_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" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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. **REST · cURL** ```bash owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \ --data-urlencode "filter[email]=dana.whitfield@example.com" \ -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$owner" OWNER_ID=$(jq -er '.data[0].id' <<<"$owner") ``` **REST · TypeScript** ```ts const owners = await coevera( `/entities/Clients?${new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name" })}`, ); const ownerId: string | undefined = owners.data[0]?.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.data[0].name, ownerId); ``` **REST · Python** ```python owners = coevera( "GET", "/entities/Clients", params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"}, ) if not owners["data"]: sys.exit("No user with that e-mail in this space.") owner_id = owners["data"][0]["id"] print("Owner:", owners["data"][0]["name"], owner_id) ``` **GraphQL · cURL** ```bash owner=$(jq -n '{ query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$owner" OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner") ``` **GraphQL · TypeScript** ```ts const owners = await gql(`{ entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } }`); const ownerId: string | undefined = owners.entities.client.getByCriteria.edges[0]?.node.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.entities.client.getByCriteria.edges[0].node.name, ownerId); ``` **GraphQL · Python** ```python owners = gql(""" { entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } } """) edges = owners["entities"]["client"]["getByCriteria"]["edges"] if not edges: sys.exit("No user with that e-mail in this space.") owner_id = edges[0]["node"]["id"] print("Owner:", edges[0]["node"]["name"], owner_id) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "client": { "getByCriteria": { "edges": [ { "node": { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 2. Create the record to keep The master: it has a sales e-mail and a phone number, but no city or comments. **REST · cURL** ```bash # 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 @- < **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: true` and its own `email1` and `name`. - Step 8 returns one relation whose `account_id` is the master and whose `modified` is 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. --- title: Lead from a web form description: Turn a form submission into a lead from your own server, with the visitor's details in the quick fields, an owner and a lead source, in REST or GraphQL. canonical: https://developers.coevera.com/recipes/lead-from-web-form/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Lead from a web form **Answer:** On your server, never in the browser, create a lead with name, an owner_id (or a unit_id), the visitor's details in quick_contact_name, quick_email, quick_account_name and quick_phone, the message in description and a lead_source_id looked up in Data. No contact or account is created. Encode the e-mail when you search by it: a raw + becomes a space. Jordan Reyes of Contoso Freight fills in the demo-request form on Northwind's website. Your form handler turns the submission into a **lead** for the sales rep, with the channel it came from, and the rep qualifies it later (see [Lead lifecycle](/recipes/lead-lifecycle/)). The form posts to your own server, and the server calls the API. Never call the API from the page itself: the application's credentials would be in every visitor's browser. ## Before you start | | | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Permissions | **Full Access** to **Leads** on the **Records** tab, and **Read Only Access** to **Common Lists** on the **Administration** tab for the lead source. 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_API_KEY` and `$COEVERA_API_SECRET`. | The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Clients`](/reference/rest/clients/#list), [`GET /entities/Data`](/reference/rest/data/#list), [`POST /entities/Leads`](/reference/rest/leads/#create), [`GET /entities/Leads`](/reference/rest/leads/#list); GraphQL [`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria), [`DataRepository.getByCriteria`](/reference/graphql/data/#q-getByCriteria), [`createLead`](/reference/graphql/lead/#m-createLead), [`LeadRepository.getById`](/reference/graphql/lead/#q-getById). ## Set up a helper Every step below uses this setup, and the submission as your form handler receives it. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # The submission, as your form handler receives it. FORM_NAME="Jordan Reyes" FORM_EMAIL="jordan.reyes+demo@example.com" FORM_COMPANY="Contoso Freight" FORM_PHONE="+1 206 555 0115" FORM_MESSAGE="We run 40 trucks and want dashboards for fuel use." ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); 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; } // The submission, as your form handler receives it. const form = { name: "Jordan Reyes", email: "jordan.reyes+demo@example.com", company: "Contoso Freight", phone: "+1 206 555 0115", message: "We run 40 trucks and want dashboards for fuel use.", }; ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 # The submission, as your form handler receives it. form = { "name": "Jordan Reyes", "email": "jordan.reyes+demo@example.com", "company": "Contoso Freight", "phone": "+1 206 555 0115", "message": "We run 40 trucks and want dashboards for fuel use.", } ``` **GraphQL · cURL** ```bash COEVERA_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" } # The submission, as your form handler receives it. FORM_NAME="Jordan Reyes" FORM_EMAIL="jordan.reyes+demo@example.com" FORM_COMPANY="Contoso Freight" FORM_PHONE="+1 206 555 0115" FORM_MESSAGE="We run 40 trucks and want dashboards for fuel use." ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } // The submission, as your form handler receives it. const form = { name: "Jordan Reyes", email: "jordan.reyes+demo@example.com", company: "Contoso Freight", phone: "+1 206 555 0115", message: "We run 40 trucks and want dashboards for fuel use.", }; ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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"] # The submission, as your form handler receives it. form = { "name": "Jordan Reyes", "email": "jordan.reyes+demo@example.com", "company": "Contoso Freight", "phone": "+1 206 555 0115", "message": "We run 40 trucks and want dashboards for fuel use.", } ``` ## 1. Find the owner Decide who gets web leads; here, one sales rep, looked up by e-mail. **REST · cURL** ```bash # Who gets web leads: here one sales rep. owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \ --data-urlencode "filter[email]=dana.whitfield@example.com" \ -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$owner" OWNER_ID=$(jq -er '.data[0].id' <<<"$owner") ``` **REST · TypeScript** ```ts // Who gets web leads: here one sales rep. const owners = await coevera( `/entities/Clients?${new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name" })}`, ); const ownerId: string | undefined = owners.data[0]?.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.data[0].name, ownerId); ``` **REST · Python** ```python # Who gets web leads: here one sales rep. owners = coevera( "GET", "/entities/Clients", params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"}, ) if not owners["data"]: sys.exit("No user with that e-mail in this space.") owner_id = owners["data"][0]["id"] print("Owner:", owners["data"][0]["name"], owner_id) ``` **GraphQL · cURL** ```bash # Who gets web leads: here one sales rep. owner=$(jq -n '{ query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$owner" OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner") ``` **GraphQL · TypeScript** ```ts // Who gets web leads: here one sales rep. const owners = await gql(`{ entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } }`); const owner = owners.entities.client.getByCriteria.edges[0]?.node; if (!owner) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owner.name, owner.id); ``` **GraphQL · Python** ```python # Who gets web leads: here one sales rep. owners = gql(""" { entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } } """)["entities"]["client"]["getByCriteria"]["edges"] if not owners: sys.exit("No user with that e-mail in this space.") owner = owners[0]["node"] print("Owner:", owner["name"], owner["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "client": { "getByCriteria": { "edges": [ { "node": { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 2. Find the lead source The lead source is a dropdown option, stored in `Data`: look its `id` up by the option's name. The program looks up Direct traffic, one of options such as Organic search, Paid ad and Referral; yours may differ. An option name can repeat in another dropdown, so check the one you get. **REST · cURL** ```bash # The lead source is a dropdown option: look its id up by the option's name. source=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Data" \ --data-urlencode "filter[option_name]=Direct traffic" \ -d load-only=id,option_name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$source" LEAD_SOURCE_ID=$(jq -er '.data[0].id' <<<"$source") ``` **REST · TypeScript** ```ts // The lead source is a dropdown option: look its id up by the option's name. const sources = await coevera(`/entities/Data?${new URLSearchParams({ "filter[option_name]": "Direct traffic", "load-only": "id,option_name" })}`); const leadSourceId: string | undefined = sources.data[0]?.id; if (!leadSourceId) throw new Error("No lead source with that name."); console.log("Lead source:", sources.data[0].option_name, leadSourceId); ``` **REST · Python** ```python # The lead source is a dropdown option: look its id up by the option's name. sources = coevera( "GET", "/entities/Data", params={"filter[option_name]": "Direct traffic", "load-only": "id,option_name"} ) if not sources["data"]: sys.exit("No lead source with that name.") lead_source_id = sources["data"][0]["id"] print("Lead source:", sources["data"][0]["option_name"], lead_source_id) ``` **GraphQL · cURL** ```bash # The lead source is a dropdown option: look its id up by the option's name. source=$(jq -n '{ query: "{ entities { data { getByCriteria(first: 1, filter: { optionName: { eq: [\"Direct traffic\"] } }) { edges { node { id optionName } } } } } }" }' | gql) echo "$source" LEAD_SOURCE_ID=$(jq -er '.data.entities.data.getByCriteria.edges[0].node.id' <<<"$source") ``` **GraphQL · TypeScript** ```ts // The lead source is a dropdown option: look its id up by the option's name. const sources = await gql(`{ entities { data { getByCriteria(first: 1, filter: { optionName: { eq: ["Direct traffic"] } }) { edges { node { id optionName } } } } } }`); const source = sources.entities.data.getByCriteria.edges[0]?.node; if (!source) throw new Error("No lead source with that name."); console.log("Lead source:", source.optionName, source.id); ``` **GraphQL · Python** ```python # The lead source is a dropdown option: look its id up by the option's name. sources = gql(""" { entities { data { getByCriteria(first: 1, filter: { optionName: { eq: ["Direct traffic"] } }) { edges { node { id optionName } } } } } } """)["entities"]["data"]["getByCriteria"]["edges"] if not sources: sys.exit("No lead source with that name.") source = sources[0]["node"] print("Lead source:", source["optionName"], source["id"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIxOTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIxOTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "19000000-0000-4000-a000-000000000001", "option_name": "Direct traffic" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "data": { "getByCriteria": { "edges": [ { "node": { "id": "00000000-0000-4000-a000-000000000001", "optionName": "Direct traffic" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 3. Create the lead A lead needs a `name` and a sales unit. Send `owner_id`, and the lead takes the owner's unit; or send only `unit_id`, and the lead waits unassigned in that unit. Without either, the create answers 422 ([Leads reference](/reference/rest/leads/#create)). The visitor's details go into the quick fields, `quick_contact_name`, `quick_email`, `quick_account_name` and `quick_phone`, and the message into `description`. The quick fields are text on the lead: no contact or account is created. **REST · cURL** ```bash # The visitor's details go into the quick fields: no contact or account is created yet. jq # builds the body, so quotes in the visitor's message cannot break the JSON. lead=$(jq -n --arg owner "$OWNER_ID" --arg source "$LEAD_SOURCE_ID" \ --arg name "$FORM_NAME" --arg email "$FORM_EMAIL" --arg company "$FORM_COMPANY" \ --arg phone "$FORM_PHONE" --arg message "$FORM_MESSAGE" \ '{ name: "\($company): demo request", owner_id: $owner, lead_source_id: $source, quick_contact_name: $name, quick_email: $email, quick_account_name: $company, quick_phone: $phone, description: $message }' | curl -sS --fail-with-body "$COEVERA_BASE/entities/Leads" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @-) echo "$lead" LEAD_ID=$(jq -er '.data.id' <<<"$lead") ``` **REST · TypeScript** ```ts // The visitor's details go into the quick fields: no contact or account is created yet. const lead = await coevera("/entities/Leads", { method: "POST", body: { name: `${form.company}: demo request`, owner_id: ownerId, lead_source_id: leadSourceId, quick_contact_name: form.name, quick_email: form.email, quick_account_name: form.company, quick_phone: form.phone, description: form.message, }, }); const leadId: string = lead.data.id; console.log("Lead:", leadId); ``` **REST · Python** ```python # The visitor's details go into the quick fields: no contact or account is created yet. lead = coevera( "POST", "/entities/Leads", json={ "name": f"{form['company']}: demo request", "owner_id": owner_id, "lead_source_id": lead_source_id, "quick_contact_name": form["name"], "quick_email": form["email"], "quick_account_name": form["company"], "quick_phone": form["phone"], "description": form["message"], }, ) lead_id = lead["data"]["id"] print("Lead:", lead_id) ``` **GraphQL · cURL** ```bash # The visitor's details go into the quick fields: no contact or account is created yet. lead=$(jq -n --arg owner "$OWNER_ID" --arg source "$LEAD_SOURCE_ID" \ --arg name "$FORM_NAME" --arg email "$FORM_EMAIL" --arg company "$FORM_COMPANY" \ --arg phone "$FORM_PHONE" --arg message "$FORM_MESSAGE" '{ query: "mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id } } }", variables: { input: { name: "\($company): demo request", ownerId: $owner, leadSourceId: $source, quickContactName: $name, quickEmail: $email, quickAccountName: $company, quickPhone: $phone, description: $message } } }' | gql) echo "$lead" LEAD_ID=$(jq -er '.data.createLead.lead.id' <<<"$lead") ``` **GraphQL · TypeScript** ```ts // The visitor's details go into the quick fields: no contact or account is created yet. const created = await gql( `mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id } } }`, { input: { name: `${form.company}: demo request`, ownerId: owner.id, leadSourceId: source.id, quickContactName: form.name, quickEmail: form.email, quickAccountName: form.company, quickPhone: form.phone, description: form.message, }, }, ); const leadId: string = created.createLead.lead.id; console.log("Lead:", leadId); ``` **GraphQL · Python** ```python # The visitor's details go into the quick fields: no contact or account is created yet. created = gql( """ mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id } } } """, { "input": { "name": f"{form['company']}: demo request", "ownerId": owner["id"], "leadSourceId": source["id"], "quickContactName": form["name"], "quickEmail": form["email"], "quickAccountName": form["company"], "quickPhone": form["phone"], "description": form["message"], } }, ) lead_id = created["createLead"]["lead"]["id"] print("Lead:", lead_id) ``` **Recorded response · REST · 201 Created** ```json { "success": true, "data": { "is_delete_protected": false, "id": "08000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.000000+00:00", "created": "2026-10-15 09:00:00.000047+00:00", "currency_exchange_rates_list": null, "lead_source": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Data/19000000-0000-4000-a000-000000000001", "lead_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/LeadTypes/00000000-0000-4000-a000-000000000001", "owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001", "reason_of_close": null, "step": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Steps/07000000-0000-4000-a000-000000000001", "unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001", "currency_exchange_rates_list_id": null, "description": "We run 40 trucks and want dashboards for fuel use.", "is_archived": false, "label_flag": 0, "last_ai_buying_center_applied": null, "lead_source_id": "19000000-0000-4000-a000-000000000001", "lead_type_id": "00000000-0000-4000-a000-000000000001", "name": "Contoso Freight: demo request", "owner_id": "0d000000-0000-4000-a000-000000000001", "quick_account_email": "", "quick_account_name": "Contoso Freight", "quick_account_phone": "", "quick_contact_name": "Jordan Reyes", "quick_email": "jordan.reyes+demo@example.com", "quick_phone": "+1 206 555 0115", "ranking": 50, "reason_of_close_description": "", "reason_of_close_id": null, "share_mode": 0, "step_id": "07000000-0000-4000-a000-000000000001", "table_name": "lead", "unit_id": "05000000-0000-4000-a000-000000000001", "revision": 1259, "primary_contact": null, "primary_account": null, "contact_relations": [], "account_relations": [], "status": 1, "sharing_units": [], "sharing_clients": [], "documents": [], "tags": [], "static_profiles": [], "qualify_date": null, "days_in_step": 0, "is_favorite": false, "lost_date": null, "formatted_name": "Contoso Freight: demo request", "scoring": null, "modified_by_user": null } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createLead": { "lead": { "id": "08000000-0000-4000-a000-000000000001" } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 4. Read it back Read the lead with its lead source expanded. The `+` in the visitor's e-mail is kept: the programs send it in a JSON body, where it needs no encoding. **REST · cURL** ```bash curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Leads" \ -d "filter[id]=$LEAD_ID" -d expand=lead_source \ -d load-only=id,name,owner_id,status,quick_contact_name,quick_email,quick_account_name,quick_phone,description,lead_source.option_name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" ``` **REST · TypeScript** ```ts const readBack = await coevera( `/entities/Leads?${new URLSearchParams({ "filter[id]": leadId, expand: "lead_source", "load-only": "id,name,owner_id,status,quick_contact_name,quick_email,quick_account_name,quick_phone,description,lead_source.option_name", })}`, ); console.log(JSON.stringify(readBack.data[0], null, 2)); ``` **REST · Python** ```python read_back = coevera( "GET", "/entities/Leads", params={ "filter[id]": lead_id, "expand": "lead_source", "load-only": "id,name,owner_id,status,quick_contact_name,quick_email,quick_account_name,quick_phone," "description,lead_source.option_name", }, ) print(json.dumps(read_back["data"][0], indent=2)) ``` **GraphQL · cURL** ```bash jq -n --arg id "$LEAD_ID" '{ query: "query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id name status owner { name } quickContactName quickEmail quickAccountName quickPhone description leadSource { optionName } } } } }", variables: { id: $id } }' | gql ``` **GraphQL · TypeScript** ```ts const readBack = await gql( `query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id name status owner { name } quickContactName quickEmail quickAccountName quickPhone description leadSource { optionName } } } } }`, { id: leadId }, ); console.log(JSON.stringify(readBack.entities.lead.getById, null, 2)); ``` **GraphQL · Python** ```python read_back = gql( """ query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id name status owner { name } quickContactName quickEmail quickAccountName quickPhone description leadSource { optionName } } } } } """, {"id": lead_id}, ) print(json.dumps(read_back["entities"]["lead"]["getById"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwODAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwODAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "08000000-0000-4000-a000-000000000001", "lead_source": { "option_name": "Direct traffic" }, "description": "We run 40 trucks and want dashboards for fuel use.", "name": "Contoso Freight: demo request", "owner_id": "0d000000-0000-4000-a000-000000000001", "quick_account_name": "Contoso Freight", "quick_contact_name": "Jordan Reyes", "quick_email": "jordan.reyes+demo@example.com", "quick_phone": "+1 206 555 0115", "status": 1 } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "lead": { "getById": { "id": "08000000-0000-4000-a000-000000000001", "name": "Contoso Freight: demo request", "status": "Open", "owner": { "name": "Dana Whitfield" }, "quickContactName": "Jordan Reyes", "quickEmail": "jordan.reyes+demo@example.com", "quickAccountName": "Contoso Freight", "quickPhone": "+1 206 555 0115", "description": "We run 40 trucks and want dashboards for fuel use.", "leadSource": { "optionName": "Direct traffic" } } } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## What to check - Step 2 returns one option. An empty `data` means no option has that name. - Step 3 answers **201** (GraphQL: 200) with the lead's `id`. - Step 4 returns the lead open (`status: 1`, GraphQL `Open`), owned by the rep, with the quick fields, the message and the lead source. - Each submission makes a new lead, so a visitor who sends the form twice gets two. To merge repeats, look for an open lead first with `filter[quick_email]` (GraphQL `quickEmail: { eq: [...] }`), and URL-encode the address: a raw `+` in a query string arrives as a space and matches nothing. ## Clean up The program leaves the lead in your space. Delete it with `POST /entities/Leads/batch-delete`. ## Limits - The program trusts the submission. Validate it, and protect the form from spam, before you call the API. - Coevera also has its own online forms, which create leads without a server of yours; this recipe does not cover them. - A lead becomes an opportunity only through GraphQL `qualifyLead`; see [Lead lifecycle](/recipes/lead-lifecycle/). --- title: Two-way contact sync description: Push contacts from another system with a webhook skip key, pull every contact changed since the last round, deletions included, and tell people's edits from integration writes. canonical: https://developers.coevera.com/recipes/two-way-contact-sync/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Two-way contact sync **Answer:** Write with a WebHook-Skip-Key header that your webhook lists in skip_keys, so your own writes do not come back. Pull contacts with filter[modified] gte the round's start, include-deleted and order-by modified. modified_by_user is set only by people's edits in Coevera apps: when it is empty or older than the round, skip the row. Northwind keeps its contacts in a second system, and both sides change them. One sync round pushes the other system's changes into Coevera, then pulls Coevera's changes back: here, a new contact, **Priya Nair**, and a new phone number for her. The pull must not echo the round's own writes, and must pass on what people changed in Coevera and what they deleted. ## Before you start | | | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Permissions | **Full Access** to **Contacts** on the **Records** tab of the application's permissions. The owner lookup also reads users (`Clients`). | | `validation-level` | The default (`0`, full validation). | | Batch behaviour | No batches: every write is one record. | | Skip key | Every write sends `WebHook-Skip-Key: northwind-sync`. A webhook whose `options.skip_keys` lists `northwind-sync` does not fire for those writes; see [Filtering and options](/webhooks/filtering-and-options/). | | Context | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`. | Two signals keep the sync from echoing itself: - **The skip key** stops your webhook from delivering your own writes. The recipe sends the header; its effect shows in your webhook's deliveries. - **`modified_by_user`** (in the app, **Modified Date by User**) is the time a person last changed the record in a Coevera app. Writes through the API or Automatizer change `modified` but leave `modified_by_user` alone: it stays `null` after the program's writes. A pulled contact whose `modified_by_user` is empty or older than the round is not a person's edit. The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Clients`](/reference/rest/clients/#list), [`POST /entities/Contacts`](/reference/rest/contacts/#create), [`PATCH /entities/Contacts/{id}`](/reference/rest/contacts/#update), [`GET /entities/Contacts`](/reference/rest/contacts/#list); GraphQL [`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria), [`createContact`](/reference/graphql/contact/#m-createContact), [`updateContact`](/reference/graphql/contact/#m-updateContact), [`ContactRepository.getByCriteria`](/reference/graphql/contact/#q-getByCriteria). ## Set up a helper Every step below uses this setup; it sends the skip key on every request. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # Every write of the sync carries this key. A webhook that lists it in options.skip_keys does # not fire for these writes, so the sync never receives its own changes back. SKIP_KEY="northwind-sync" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // Every write of the sync carries this key. A webhook that lists it in options.skip_keys does // not fire for these writes, so the sync never receives its own changes back. const SKIP_KEY = "northwind-sync"; async function coevera(path: string, init: { method?: string; body?: unknown } = {}) { const res = await fetch(`${base}${path}`, { method: init.method ?? "GET", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", "WebHook-Skip-Key": SKIP_KEY }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${String(body.message).trim()}`); return body; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) # Every write of the sync carries this key. A webhook that lists it in options.skip_keys does # not fire for these writes, so the sync never receives its own changes back. session.headers["WebHook-Skip-Key"] = "northwind-sync" def coevera(method, path, **kwargs): response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {str(body.get('message')).strip()}") return body def utc(value): """A date-time from the API as an aware datetime: the API answers in UTC unless asked otherwise.""" parsed = datetime.fromisoformat(value) return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" # Every write of the sync carries this key. A webhook that lists it in options.skip_keys does # not fire for these writes, so the sync never receives its own changes back. SKIP_KEY="northwind-sync" # gql: POST the GraphQL request read from stdin. GraphQL reports errors in the body, often # with HTTP 200, so fail on those too. gql() { local response response=$(curl -sS --fail-with-body "$COEVERA_GRAPHQL" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -H "WebHook-Skip-Key: $SKIP_KEY" \ -d @-) if jq -e '.errors' <<<"$response" >/dev/null; then echo "$response" >&2 return 1 fi echo "$response" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // Every write of the sync carries this key. A webhook that lists it in options.skip_keys does // not fire for these writes, so the sync never receives its own changes back. const SKIP_KEY = "northwind-sync"; // gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200. async function gql(query: string, variables: object = {}) { const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", "WebHook-Skip-Key": SKIP_KEY }, body: JSON.stringify({ query, variables }), }); const body = await res.json(); if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`); return body.data; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) # Every write of the sync carries this key. A webhook that lists it in options.skip_keys does # not fire for these writes, so the sync never receives its own changes back. session.headers["WebHook-Skip-Key"] = "northwind-sync" def gql(query, variables=None): """One GraphQL request. GraphQL reports errors in the body, often with HTTP 200.""" response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30) body = response.json() if not response.ok or body.get("errors"): sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}") return body["data"] def utc(value): """A date-time from the API as an aware datetime: the API answers in UTC unless asked otherwise.""" parsed = datetime.fromisoformat(value) return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) ``` ## 1. Find the owner A new contact needs an owner. Look the sales rep up by e-mail. **REST · cURL** ```bash owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \ --data-urlencode "filter[email]=dana.whitfield@example.com" \ -d load-only=id,name \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$owner" OWNER_ID=$(jq -er '.data[0].id' <<<"$owner") ``` **REST · TypeScript** ```ts const owners = await coevera( `/entities/Clients?${new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name" })}`, ); const ownerId: string | undefined = owners.data[0]?.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.data[0].name, ownerId); ``` **REST · Python** ```python owners = coevera( "GET", "/entities/Clients", params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"}, ) if not owners["data"]: sys.exit("No user with that e-mail in this space.") owner_id = owners["data"][0]["id"] print("Owner:", owners["data"][0]["name"], owner_id) ``` **GraphQL · cURL** ```bash owner=$(jq -n '{ query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }" }' | gql) echo "$owner" OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner") ``` **GraphQL · TypeScript** ```ts const owners = await gql(`{ entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } }`); const ownerId: string | undefined = owners.entities.client.getByCriteria.edges[0]?.node.id; if (!ownerId) throw new Error("No user with that e-mail in this space."); console.log("Owner:", owners.entities.client.getByCriteria.edges[0].node.name, ownerId); ``` **GraphQL · Python** ```python owners = gql(""" { entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name } } } } } } """) edges = owners["entities"]["client"]["getByCriteria"]["edges"] if not edges: sys.exit("No user with that e-mail in this space.") owner_id = edges[0]["node"]["id"] print("Owner:", edges[0]["node"]["name"], owner_id) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "client": { "getByCriteria": { "edges": [ { "node": { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 2. Push a new contact The round starts here: the program keeps the time, a minute back to allow for clock drift, as the round's start. A real sync stores it, and the next round pulls from the newest `modified` it has seen. Then it creates the contact the other system added. **REST · cURL** ```bash # The start of this round, in UTC. A real sync stores it and starts the next round from it. SINCE=$(date -u -d '1 minute ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-1M +%Y-%m-%dT%H:%M:%SZ) # A contact that is new in the other system. contact=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Contacts" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -H "WebHook-Skip-Key: $SKIP_KEY" \ -d @- </dev/null || date -u -v-1M +%Y-%m-%dT%H:%M:%SZ) # A contact that is new in the other system. contact=$(jq -n --arg owner "$OWNER_ID" '{ query: "mutation PushNew($input: CreateContactInput!) { createContact(input: $input) { contact { id } } }", variables: { input: { firstName: "Priya", lastName: "Nair", email1: "priya.nair@example.com", ownerId: $owner } } }' | gql) echo "$contact" CONTACT_ID=$(jq -er '.data.createContact.contact.id' <<<"$contact") ``` **GraphQL · TypeScript** ```ts // The start of this round. A real sync stores it and starts the next round from it. const since = new Date(Date.now() - 60_000).toISOString(); // A contact that is new in the other system. const created = await gql( `mutation PushNew($input: CreateContactInput!) { createContact(input: $input) { contact { id } } }`, { input: { firstName: "Priya", lastName: "Nair", email1: "priya.nair@example.com", ownerId } }, ); const contactId: string = created.createContact.contact.id; console.log("Pushed:", contactId); ``` **GraphQL · Python** ```python # The start of this round. A real sync stores it and starts the next round from it. since = datetime.now(timezone.utc) - timedelta(minutes=1) # A contact that is new in the other system. created = gql( """ mutation PushNew($input: CreateContactInput!) { createContact(input: $input) { contact { id } } } """, {"input": {"firstName": "Priya", "lastName": "Nair", "email1": "priya.nair@example.com", "ownerId": owner_id}}, ) contact_id = created["createContact"]["contact"]["id"] print("Pushed:", contact_id) ``` **Recorded response · REST · 201 Created** ```json { "success": true, "data": { "is_delete_protected": false, "id": "0c000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:58.836143+00:00", "created": "2026-10-15 09:00:58.836195+00:00", "contact_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ContactTypes/00000000-0000-4000-a000-000000000001", "currency_exchange_rates_list": null, "owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001", "picture": null, "unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001", "address": "", "city": "", "comments": "", "contact_type_id": "00000000-0000-4000-a000-000000000001", "country": "", "currency_exchange_rates_list_id": null, "email1": "priya.nair@example.com", "email2": "", "email3": "", "email4": "", "email5": "", "first_name": "Priya", "gender": 0, "is_archived": false, "last_name": "Nair", "middle_name": "", "owner_id": "0d000000-0000-4000-a000-000000000001", "phone1": "", "phone2": "", "phone3": "", "phone4": "", "phone5": "", "picture_id": null, "position": "", "quick_account_name": "", "share_mode": 0, "state_province": "", "title": "", "unit_id": "05000000-0000-4000-a000-000000000001", "zip_code": "", "revision": 1244, "formatted_name": "Priya Nair", "social_media": null, "primary_account": null, "account_relations": [], "sharing_units": [], "sharing_clients": [], "documents": [], "exchange_entity_id": null, "gapi_entity_id": null, "o365_entity_id": null, "is_favorite": false, "primary_account_account_roles": null, "primary_account_position": null, "account_position": "", "is_unsubscribed": false, "tags": [], "static_profiles": [], "primary_account_relationship": null, "modified_by_user": null } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createContact": { "contact": { "id": "0c000000-0000-4000-a000-000000000001" } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 3. Push a change The other system changed Priya's phone number. **REST · cURL** ```bash # A change made in the other system. curl -sS --fail-with-body -X PATCH "$COEVERA_BASE/entities/Contacts/$CONTACT_ID" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -H "WebHook-Skip-Key: $SKIP_KEY" \ -d '{ "phone1": "+1 206 555 0141" }' >/dev/null echo "Pushed the new phone number." ``` **REST · TypeScript** ```ts // A change made in the other system. await coevera(`/entities/Contacts/${contactId}`, { method: "PATCH", body: { phone1: "+1 206 555 0141" } }); console.log("Pushed the new phone number."); ``` **REST · Python** ```python # A change made in the other system. coevera("PATCH", f"/entities/Contacts/{contact_id}", json={"phone1": "+1 206 555 0141"}) print("Pushed the new phone number.") ``` **GraphQL · cURL** ```bash # A change made in the other system. jq -n --arg id "$CONTACT_ID" '{ query: "mutation PushChange($input: UpdateContactInput!) { updateContact(input: $input) { contact { id phone1 } } }", variables: { input: { id: $id, phone1: "+1 206 555 0141" } } }' | gql ``` **GraphQL · TypeScript** ```ts // A change made in the other system. await gql( `mutation PushChange($input: UpdateContactInput!) { updateContact(input: $input) { contact { id phone1 } } }`, { input: { id: contactId, phone1: "+1 206 555 0141" } }, ); console.log("Pushed the new phone number."); ``` **GraphQL · Python** ```python # A change made in the other system. gql( """ mutation PushChange($input: UpdateContactInput!) { updateContact(input: $input) { contact { id phone1 } } } """, {"input": {"id": contact_id, "phone1": "+1 206 555 0141"}}, ) print("Pushed the new phone number.") ``` **Recorded response · REST · 200 OK** ```json { "success": true, "data": { "is_delete_protected": false, "id": "0c000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:59.383553+00:00", "created": "2026-10-15 09:00:58.836195+00:00", "contact_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ContactTypes/00000000-0000-4000-a000-000000000001", "currency_exchange_rates_list": null, "owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001", "picture": null, "unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001", "address": "", "city": "", "comments": "", "contact_type_id": "00000000-0000-4000-a000-000000000001", "country": "", "currency_exchange_rates_list_id": null, "email1": "priya.nair@example.com", "email2": "", "email3": "", "email4": "", "email5": "", "first_name": "Priya", "gender": 0, "is_archived": false, "last_name": "Nair", "middle_name": "", "owner_id": "0d000000-0000-4000-a000-000000000001", "phone1": "+1 206 555 0141", "phone2": "", "phone3": "", "phone4": "", "phone5": "", "picture_id": null, "position": "", "quick_account_name": "", "share_mode": 0, "state_province": "", "title": "", "unit_id": "05000000-0000-4000-a000-000000000001", "zip_code": "", "revision": 1245, "formatted_name": "Priya Nair", "social_media": null, "primary_account": null, "account_relations": [], "sharing_units": [], "sharing_clients": [], "documents": [], "exchange_entity_id": null, "gapi_entity_id": null, "o365_entity_id": null, "is_favorite": false, "primary_account_account_roles": null, "primary_account_position": null, "account_position": "", "is_unsubscribed": false, "tags": [], "static_profiles": [], "primary_account_relationship": null, "modified_by_user": null } } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "updateContact": { "contact": { "id": "0c000000-0000-4000-a000-000000000001", "phone1": "+1 206 555 0141" } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ ## 4. Pull what changed Ask for every contact with `modified` at or after the round's start (`filter-op[modified]=gte`), oldest first, with `include-deleted=true` so that deletions come too. Send the date encoded: a raw `+` in an offset would arrive as a space. Then decide per contact: | The pulled contact | What to do on your side | | ------------------------------------------------ | ------------------------------------------------------ | | `is_deleted: true` | Delete it. | | `modified_by_user` at or after the round's start | A person changed it in Coevera: apply the change. | | `modified_by_user` empty or older | Written by an integration, this one included: skip it. | **REST · cURL** ```bash # Everything changed since the round started, deletions included, oldest first. changes=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Contacts" \ --data-urlencode "filter[modified]=$SINCE" \ -d "filter-op[modified]=gte" -d include-deleted=true -d order-by=modified -d first=100 \ -d load-only=id,first_name,last_name,email1,phone1,modified,modified_by_user,is_deleted \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$changes" # modified_by_user is when a person last changed the record in a Coevera app; API and # Automatizer writes leave it alone. Older than this round: not a person's edit. jq -r --arg since "$SINCE" '.data[] | (if .is_deleted then "delete on your side" elif .modified_by_user != null and (.modified_by_user | sub(" "; "T") | .[0:19]) >= ($since | .[0:19]) then "apply to your side" else "skip: written by an integration" end) as $action | "\(.first_name) \(.last_name) <\(.email1)>: \($action)"' <<<"$changes" ``` **REST · TypeScript** ```ts // Everything changed since the round started, deletions included, oldest first. The date goes // through URLSearchParams: a raw "+" in an offset would arrive as a space. const changes = await coevera( `/entities/Contacts?${new URLSearchParams({ "filter[modified]": since, "filter-op[modified]": "gte", "include-deleted": "true", "order-by": "modified", first: "100", "load-only": "id,first_name,last_name,email1,phone1,modified,modified_by_user,is_deleted", })}`, ); for (const c of changes.data) { // modified_by_user is when a person last changed the record in a Coevera app; API and // Automatizer writes leave it alone. Older than this round: not a person's edit. const byPerson = c.modified_by_user !== null && Date.parse(c.modified_by_user.replace(" ", "T")) >= Date.parse(since); const action = c.is_deleted ? "delete on your side" : byPerson ? "apply to your side" : "skip: written by an integration"; console.log(`${c.first_name} ${c.last_name} <${c.email1}>: ${action}`); } ``` **REST · Python** ```python # Everything changed since the round started, deletions included, oldest first. requests # encodes the date: a raw "+" in an offset would arrive as a space. changes = coevera( "GET", "/entities/Contacts", params={ "filter[modified]": since.isoformat(), "filter-op[modified]": "gte", "include-deleted": "true", "order-by": "modified", "first": 100, "load-only": "id,first_name,last_name,email1,phone1,modified,modified_by_user,is_deleted", }, ) for c in changes["data"]: # modified_by_user is when a person last changed the record in a Coevera app; API and # Automatizer writes leave it alone. Older than this round: not a person's edit. by_person = c["modified_by_user"] is not None and utc(c["modified_by_user"]) >= since if c["is_deleted"]: action = "delete on your side" elif by_person: action = "apply to your side" else: action = "skip: written by an integration" print(f"{c['first_name']} {c['last_name']} <{c['email1']}>: {action}") ``` **GraphQL · cURL** ```bash # Everything changed since the round started, deletions included, oldest first. changes=$(jq -n --arg since "$SINCE" '{ query: "query Pull($since: DateTime!) { entities { contact { getByCriteria(first: 100, includeDeleted: true, filter: { modified: { gte: $since } }, orderBy: [{ modified: Asc }]) { edges { node { id firstName lastName email1 phone1 modified modifiedByUser isDeleted } } } } } }", variables: { since: $since } }' | gql) echo "$changes" # modifiedByUser is when a person last changed the record in a Coevera app; API and # Automatizer writes leave it alone. Older than this round: not a person's edit. jq -r --arg since "$SINCE" '.data.entities.contact.getByCriteria.edges[].node | (if .isDeleted then "delete on your side" elif .modifiedByUser != null and (.modifiedByUser | .[0:19]) >= ($since | .[0:19]) then "apply to your side" else "skip: written by an integration" end) as $action | "\(.firstName) \(.lastName) <\(.email1)>: \($action)"' <<<"$changes" ``` **GraphQL · TypeScript** ```ts // Everything changed since the round started, deletions included, oldest first. const changes = await gql( `query Pull($since: DateTime!) { entities { contact { getByCriteria(first: 100, includeDeleted: true, filter: { modified: { gte: $since } }, orderBy: [{ modified: Asc }]) { edges { node { id firstName lastName email1 phone1 modified modifiedByUser isDeleted } } } } } }`, { since }, ); for (const { node: c } of changes.entities.contact.getByCriteria.edges) { // modifiedByUser is when a person last changed the record in a Coevera app; API and // Automatizer writes leave it alone. Older than this round: not a person's edit. const byPerson = c.modifiedByUser !== null && Date.parse(c.modifiedByUser) >= Date.parse(since); const action = c.isDeleted ? "delete on your side" : byPerson ? "apply to your side" : "skip: written by an integration"; console.log(`${c.firstName} ${c.lastName} <${c.email1}>: ${action}`); } ``` **GraphQL · Python** ```python # Everything changed since the round started, deletions included, oldest first. changes = gql( """ query Pull($since: DateTime!) { entities { contact { getByCriteria( first: 100 includeDeleted: true filter: { modified: { gte: $since } } orderBy: [{ modified: Asc }] ) { edges { node { id firstName lastName email1 phone1 modified modifiedByUser isDeleted } } } } } } """, {"since": since.isoformat()}, ) for edge in changes["entities"]["contact"]["getByCriteria"]["edges"]: c = edge["node"] # modifiedByUser is when a person last changed the record in a Coevera app; API and # Automatizer writes leave it alone. Older than this round: not a person's edit. by_person = c["modifiedByUser"] is not None and utc(c["modifiedByUser"]) >= since if c["isDeleted"]: action = "delete on your side" elif by_person: action = "apply to your side" else: action = "skip: written by an integration" print(f"{c['firstName']} {c['lastName']} <{c['email1']}>: {action}") ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIyMDI2LTEwLTE1VDEwOjQ2OjQzLjk3MDU1MyswMDowMCIsICIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIyMDI2LTEwLTE1VDEwOjQ2OjQzLjk3MDU1MyswMDowMCIsICIwYzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "0c000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:59.383553+00:00", "email1": "priya.nair@example.com", "first_name": "Priya", "last_name": "Nair", "phone1": "+1 206 555 0141", "modified_by_user": null } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "contact": { "getByCriteria": { "edges": [ { "node": { "id": "0c000000-0000-4000-a000-000000000002", "firstName": "Priya", "lastName": "Nair", "email1": "priya.nair@example.com", "phone1": "+1 206 555 0141", "modified": "2026-10-15T09:00:00.000000+00:00", "modifiedByUser": null, "isDeleted": false } }, { "node": { "id": "0c000000-0000-4000-a000-000000000001", "firstName": "Priya", "lastName": "Nair", "email1": "priya.nair@example.com", "phone1": "+1 206 555 0141", "modified": "2026-10-15T09:00:01.389544+00:00", "modifiedByUser": null, "isDeleted": false } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.3 in the docs sandbox, de-identified._ The pull returns every contact changed in the window, not only the round's own: in the GraphQL recording, the first row is the contact the REST program had pushed a moment before. ## What to check - Steps 2 and 3 answer **201** and **200** (GraphQL: 200). - Step 4 returns Priya with the new `phone1`, `is_deleted: false` and `modified_by_user: null` (GraphQL `modifiedByUser`), so the program skips her: no echo. - The rows come oldest `modified` first. - Run the program twice and the second round pushes a second Priya Nair: the API does not refuse duplicates. A real sync keeps the Coevera id of every record it pushed and updates instead. ## Clean up The program leaves the contact in your space. Delete it with `POST /entities/Contacts/batch-delete`; the next pull with `include-deleted=true` reports it with `is_deleted: true`, which is how your side learns of deletions. ## Limits - One page holds up to 100 contacts. A round with more changes follows the cursor; see [Pagination](/concepts/pagination/). - `modified_by_user` tells people from integrations, not one integration from another. Which user or application made a change is not readable through the public API on contacts. - Conflicts (both sides changed the same contact in one round) are yours to settle: compare `modified_by_user` with the time of your own change. --- title: Build a webhook receiver description: A webhook receiver in TypeScript or Python that verifies signatures, answers fast, skips duplicates and survives retries, with a self-test and a cURL script to send it deliveries. canonical: https://developers.coevera.com/recipes/webhook-receiver/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Build a webhook receiver **Answer:** Read the raw body and verify WebHook-Signature in constant time, answering 401 on a mismatch (never 403 or 410). Answer 200 at once and process afterwards. Skip events whose event, entity.id, entity.revision and event_time you have seen, drop events older than 25 hours, and keep the highest revision per record. Accept the DELETE sent when the webhook is deleted. This receiver accepts the deliveries of a Coevera webhook, such as one subscribed to `Opportunity.Update` for **Northwind Analytics**' deals, and handles each event once, in the right order, however often it arrives. It has no dependencies beyond Node.js or Python's `requests`, and a self-test that proves it handles retries, replays and forgeries. The receiver itself calls no API. Its self-test reads one opportunity over REST to build a realistic delivery, because deliveries carry records in the REST API's format; that is why the samples have no GraphQL version. ## Before you begin - A webhook with a `signature`, and its value in `WEBHOOK_SECRET` ([create a webhook](/webhooks/overview/#create-a-webhook)). The self-test makes one up if it is not set. - For the self-test, an API application that can read opportunities, and its credentials in `COEVERA_API_KEY` and `COEVERA_API_SECRET` ([quickstart](/get-started/quickstart/)). - Node.js 18 or later, or Python 3.9 or later with `requests`. The cURL script needs `curl`, `jq` and `openssl`. The operations this recipe calls, in the API reference: REST [`GET /entities/Opportunities`](/reference/rest/opportunities/#list). ## 1. Verify the signature Check the bytes as they arrived, before any JSON parsing, against `WebHook-Signature`, and compare in constant time. The cURL tab computes the same value with `openssl`, to check a delivery by hand. _This sample is REST only._ **REST · cURL** ```bash # signature_of BODY: lowercase hex HMAC-SHA256 of the exact bytes, keyed with the webhook's # signature value. verify_signature BODY HEADER: succeeds when HEADER is that signature. signature_of() { printf '%s' "$1" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //' } verify_signature() { [ "$(signature_of "$1")" = "$(printf '%s' "$2" | tr -d '[:space:]' | tr 'A-F' 'a-f')" ] } ``` **REST · TypeScript** ```ts // WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the // webhook's signature value. Check the bytes as received, before parsing, in constant time. function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false; const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex")); const received = Buffer.from(header.trim().toLowerCase()); return expected.length === received.length && timingSafeEqual(expected, received); } ``` **REST · Python** ```python # WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the # webhook's signature value. Check the bytes as received, before parsing, in constant time. def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool: if not header: return False expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header.strip().lower()) ``` **What to check:** a delivery with a wrong signature gets 401. Never answer it with 403 or 410: Coevera disables the webhook at once on either. ## 2. Skip duplicates and old events A delivery has no ID, and a retry sends the same body again for about 24 hours. The receiver keys each event on `event`, `entity.id`, `entity.revision` and `event_time` and skips a key it has seen, which makes its processing idempotent: an event that arrives twice is handled once. It drops events older than 25 hours, since a replayed body still verifies. `event_time` is UTC without an offset: the TypeScript version adds the `Z`, or JavaScript would read it as local time. In production, keep the keys in your database, not in memory. This is the `deduplicate` step of the program below: `firstTime` (`first_time` in Python) and `ageMs` (`age`). ## 3. Keep the newest revision Deliveries are not ordered, and each chunk retries on its own, so an older revision can arrive after a newer one. The receiver stores a record only if its `revision` is higher than the one it has; every write is safe to repeat. Keep `id` and `revision` in the record if the webhook trims it with `load-only`. This is `processEvent` (`process_event`) in the program below. ## 4. Answer first, then process The handler answers 200 as soon as the signature checks out and the body parses, and only then processes the events: Coevera waits at most 10 seconds. It answers 400 to a body that is not a list of events, and 200 to the `DELETE` Coevera sends when the webhook is deleted. In production, hand the events to a job queue. The whole program, steps 1 to 4 and the self-test below; the cURL tab is the test-delivery script: _This sample is REST only._ **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" SPACE_ID="{space_id}" RECEIVER_URL="${RECEIVER_URL:-http://localhost:8787/coevera-webhooks}" # signature_of BODY: lowercase hex HMAC-SHA256 of the exact bytes, keyed with the webhook's # signature value. verify_signature BODY HEADER: succeeds when HEADER is that signature. signature_of() { printf '%s' "$1" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //' } verify_signature() { [ "$(signature_of "$1")" = "$(printf '%s' "$2" | tr -d '[:space:]' | tr 'A-F' 'a-f')" ] } # The test delivery carries a real record, as the REST API returns it. record=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ -d first=1 \ -d load-only=id,name,owner_id,step_id,revision \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" | jq -ce '.data[0]') # One line of JSON, like a delivery; event_time is UTC without an offset. body=$(jq -cn --argjson entity "$record" --arg url "$RECEIVER_URL" --arg space "$SPACE_ID" \ --arg time "$(date -u +%Y-%m-%dT%H:%M:%S)" \ '[{webhook: {url: $url, events: ["Opportunity.Update"]}, event: "Opportunity.Update", event_time: $time, team_space_id: $space, entity: $entity, related_entity: null}]') signature=$(signature_of "$body") verify_signature "$body" "$signature" curl -sS --fail-with-body "$RECEIVER_URL" \ -H "Content-Type: application/json" \ -H "WebHook-Signature: $signature" \ -H "WebHook-Signature-Version: v1" \ -H "Webhook-Delivery-Retry: 0" \ --data-binary "$body" echo ``` **REST · TypeScript** ```ts import { Buffer } from "node:buffer"; import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { argv, env } from "node:process"; const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const spaceId = "{space_id}"; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); const port = Number(env.PORT ?? 8787); // WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the // webhook's signature value. Check the bytes as received, before parsing, in constant time. function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false; const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex")); const received = Buffer.from(header.trim().toLowerCase()); return expected.length === received.length && timingSafeEqual(expected, received); } interface WebhookEvent { event: string; event_time: string; team_space_id: string; entity: { id: string; revision?: number; [field: string]: unknown }; related_entity: Record | null; } // A delivery has no id, and a retry sends the same body again for about 24 hours. Remember // what you processed for longer than that (in a database, in production), and drop older // events: a replayed body still carries a valid signature. const MAX_AGE_MS = 25 * 60 * 60 * 1000; const seen = new Map(); function ageMs(e: WebhookEvent, now: number): number { // event_time is UTC. Without an offset, JavaScript would read it as local time: add the Z. const time = /(?:Z|[+-]\d\d:\d\d)$/.test(e.event_time) ? e.event_time : `${e.event_time}Z`; return now - Date.parse(time); } function firstTime(e: WebhookEvent, now: number): boolean { for (const [key, at] of seen) if (now - at > MAX_AGE_MS) seen.delete(key); const key = [e.event, e.entity.id, e.entity.revision, e.event_time].join("|"); if (seen.has(key)) return false; seen.set(key, now); return true; } // Deliveries are not ordered, and each chunk retries on its own: an older revision can arrive // after a newer one. Keep the newest, and make every write safe to repeat. const records = new Map }>(); function processEvent(e: WebhookEvent): string { const revision = e.entity.revision ?? 0; const current = records.get(e.entity.id); if (current && current.revision >= revision) return `kept revision ${current.revision}`; records.set(e.entity.id, { revision, deleted: e.event.endsWith(".Delete"), data: e.entity }); return `stored revision ${revision}`; } // The webhook's signature value. The self-test makes one up when WEBHOOK_SECRET is not set. const secret = env.WEBHOOK_SECRET ?? randomUUID(); function reply(res: ServerResponse, status: number, body: object): void { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); } function header(req: IncomingMessage, name: string): string | undefined { const value = req.headers[name]; return Array.isArray(value) ? value[0] : value; } // Answer within 10 seconds, then process. Never answer 403 or 410 (Coevera disables the // webhook at once on either): a bad signature gets 401. const server = createServer(async (req, res) => { // Deleting the webhook sends DELETE to its URL: accept it. if (req.method === "DELETE") return reply(res, 200, { removed: true }); if (req.method !== "POST") return reply(res, 405, { error: "POST only" }); const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const rawBody = Buffer.concat(chunks); if (!verifySignature(rawBody, header(req, "webhook-signature"), secret)) return reply(res, 401, { error: "bad signature" }); let events: WebhookEvent[]; try { events = JSON.parse(rawBody.toString("utf8")); } catch { return reply(res, 400, { error: "not JSON" }); } if (!Array.isArray(events)) return reply(res, 400, { error: "not a list of events" }); reply(res, 200, { received: events.length }); // The answer is on its way. In production, hand the events to a job queue here. const now = Date.now(); for (const e of events) { const outcome = ageMs(e, now) > MAX_AGE_MS ? "too old, dropped" : firstTime(e, now) ? processEvent(e) : "duplicate, skipped"; console.log(`${e.event} ${e.entity.id}: ${outcome}`); } }); // The test delivery carries a real record, as the REST API returns it. async function findRecord(): Promise> { const res = await fetch(`${base}/entities/Opportunities?first=1&load-only=id,name,owner_id,step_id,revision`, { headers: { Authorization: `Basic ${auth}` }, }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${body.message}`); if (!body.data.length) throw new Error("The self-test needs one opportunity in the space."); return body.data[0]; } // A delivery signed the way Coevera signs, then what retries, replays, forgeries and a // deleted webhook look like to the receiver. async function selfTest(url: string): Promise { const record = await findRecord(); const delivery = (eventTime: Date) => JSON.stringify([ { webhook: { id: randomUUID(), url, events: ["Opportunity.Update"] }, event: "Opportunity.Update", event_time: eventTime.toISOString().slice(0, -1), team_space_id: spaceId, entity: record, related_entity: null, }, ]); const send = (body: string, signature: string, retry = 0) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": String(retry) }, body, }).then((r) => r.status); const sign = (body: string) => createHmac("sha256", secret).update(body).digest("hex"); const fresh = delivery(new Date()); const old = delivery(new Date(Date.now() - 48 * 60 * 60 * 1000)); const answers = { first: await send(fresh, sign(fresh)), retry: await send(fresh, sign(fresh), 1), old: await send(old, sign(old)), forged: await send(fresh, "0".repeat(64)), deleted: await fetch(url, { method: "DELETE" }).then((r) => r.status), }; console.log("Answers:", JSON.stringify(answers)); const expected = { first: 200, retry: 200, old: 200, forged: 401, deleted: 200 }; if (JSON.stringify(answers) !== JSON.stringify(expected)) throw new Error(`Expected ${JSON.stringify(expected)}`); if (records.size !== 1 || seen.size !== 1) throw new Error("A retry or an old event was processed."); } if (argv.includes("--serve")) { server.listen(port, () => console.log(`Listening on http://localhost:${port}/`)); } else { server.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); const { port: testPort } = server.address() as AddressInfo; try { await selfTest(`http://127.0.0.1:${testPort}/coevera-webhooks`); console.log("Self-test passed."); } finally { server.close(); server.closeAllConnections(); } } ``` **REST · Python** ```python import hashlib import hmac import json import os import queue import sys import threading import uuid from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Optional import requests BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" SPACE_ID = "{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) PORT = int(os.environ.get("PORT", "8787")) # WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the # webhook's signature value. Check the bytes as received, before parsing, in constant time. def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool: if not header: return False expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header.strip().lower()) # A delivery has no id, and a retry sends the same body again for about 24 hours. Remember # what you processed for longer than that (in a database, in production), and drop older # events: a replayed body still carries a valid signature. MAX_AGE = timedelta(hours=25) seen = {} seen_lock = threading.Lock() def age(event, now): # event_time is UTC; read it as UTC whether or not it carries an offset. when = datetime.fromisoformat(event["event_time"].replace("Z", "+00:00")) if when.tzinfo is None: when = when.replace(tzinfo=timezone.utc) return now - when def first_time(event, now): key = "|".join(str(part) for part in (event["event"], event["entity"]["id"], event["entity"].get("revision"), event["event_time"])) with seen_lock: for old_key, at in list(seen.items()): if now - at > MAX_AGE: del seen[old_key] if key in seen: return False seen[key] = now return True # Deliveries are not ordered, and each chunk retries on its own: an older revision can arrive # after a newer one. Keep the newest, and make every write safe to repeat. records = {} def process_event(event): entity = event["entity"] revision = entity.get("revision") or 0 current = records.get(entity["id"]) if current and current["revision"] >= revision: return f"kept revision {current['revision']}" records[entity["id"]] = {"revision": revision, "deleted": event["event"].endswith(".Delete"), "data": entity} return f"stored revision {revision}" # The webhook's signature value. The self-test makes one up when WEBHOOK_SECRET is not set. SECRET = os.environ.get("WEBHOOK_SECRET") or str(uuid.uuid4()) jobs = queue.Queue() def worker(): while True: events = jobs.get() now = datetime.now(timezone.utc) for event in events: if age(event, now) > MAX_AGE: outcome = "too old, dropped" elif first_time(event, now): outcome = process_event(event) else: outcome = "duplicate, skipped" print(f"{event['event']} {event['entity']['id']}: {outcome}", flush=True) jobs.task_done() class Receiver(BaseHTTPRequestHandler): # Answer within 10 seconds, then process. Never answer 403 or 410 (Coevera disables the # webhook at once on either): a bad signature gets 401. def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify_signature(raw_body, self.headers.get("WebHook-Signature"), SECRET): return self.reply(401, {"error": "bad signature"}) try: events = json.loads(raw_body) except ValueError: return self.reply(400, {"error": "not JSON"}) if not isinstance(events, list): return self.reply(400, {"error": "not a list of events"}) self.reply(200, {"received": len(events)}) jobs.put(events) def do_DELETE(self): # Deleting the webhook sends DELETE to its URL: accept it. self.reply(200, {"removed": True}) def reply(self, status, body): payload = json.dumps(body).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, format, *args): pass threading.Thread(target=worker, daemon=True).start() # The test delivery carries a real record, as the REST API returns it. def find_record(): response = session.get( f"{BASE}/entities/Opportunities", params={"first": 1, "load-only": "id,name,owner_id,step_id,revision"}, timeout=30, ) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {body.get('message')}") if not body["data"]: sys.exit("The self-test needs one opportunity in the space.") return body["data"][0] # A delivery signed the way Coevera signs, then what retries, replays, forgeries and a # deleted webhook look like to the receiver. def self_test(url): record = find_record() def delivery(event_time): return json.dumps([{ "webhook": {"id": str(uuid.uuid4()), "url": url, "events": ["Opportunity.Update"]}, "event": "Opportunity.Update", "event_time": event_time.replace(tzinfo=None).isoformat(), "team_space_id": SPACE_ID, "entity": record, "related_entity": None, }]).encode("utf-8") def sign(body): return hmac.new(SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() def send(body, signature, retry=0): headers = { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": str(retry), } status = requests.post(url, data=body, headers=headers, timeout=10).status_code jobs.join() # wait until the receiver has processed it, so the checks below see the result return status now = datetime.now(timezone.utc) fresh = delivery(now) old = delivery(now - timedelta(hours=48)) answers = { "first": send(fresh, sign(fresh)), "retry": send(fresh, sign(fresh), 1), "old": send(old, sign(old)), "forged": send(fresh, "0" * 64), "deleted": requests.delete(url, timeout=10).status_code, } print("Answers:", json.dumps(answers)) expected = {"first": 200, "retry": 200, "old": 200, "forged": 401, "deleted": 200} if answers != expected: sys.exit(f"Expected {json.dumps(expected)}") if len(records) != 1 or len(seen) != 1: sys.exit("A retry or an old event was processed.") if "--serve" in sys.argv: port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else PORT server = ThreadingHTTPServer(("", port), Receiver) print(f"Listening on http://localhost:{server.server_port}/", flush=True) server.serve_forever() else: server = ThreadingHTTPServer(("127.0.0.1", 0), Receiver) threading.Thread(target=server.serve_forever, daemon=True).start() try: self_test(f"http://127.0.0.1:{server.server_port}/coevera-webhooks") print("Self-test passed.") finally: server.shutdown() ``` **What to check:** a delivery gets its 200 before any event is processed, and a `DELETE` gets 200. ## 5. Test it Run the receiver without arguments for the self-test. It starts on a free port and sends itself a delivery built from one of your opportunities, the same delivery again, one from two days ago, a forged one and a `DELETE`: ```bash npx tsx index.ts ``` It prints `Answers: {"first":200,"retry":200,"old":200,"forged":401,"deleted":200}`, then `Self-test passed.`; `python main.py` does the same. The self-test builds its delivery from the first opportunity it finds: _This sample is REST only._ **REST · cURL** ```bash # The test delivery carries a real record, as the REST API returns it. record=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ -d first=1 \ -d load-only=id,name,owner_id,step_id,revision \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" | jq -ce '.data[0]') ``` **REST · TypeScript** ```ts // The test delivery carries a real record, as the REST API returns it. async function findRecord(): Promise> { const res = await fetch(`${base}/entities/Opportunities?first=1&load-only=id,name,owner_id,step_id,revision`, { headers: { Authorization: `Basic ${auth}` }, }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${body.message}`); if (!body.data.length) throw new Error("The self-test needs one opportunity in the space."); return body.data[0]; } ``` **REST · Python** ```python # The test delivery carries a real record, as the REST API returns it. def find_record(): response = session.get( f"{BASE}/entities/Opportunities", params={"first": 1, "load-only": "id,name,owner_id,step_id,revision"}, timeout=30, ) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {body.get('message')}") if not body["data"]: sys.exit("The self-test needs one opportunity in the space.") return body["data"][0] ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 3, "page_info": { "start_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": true }, "data": [ { "id": "0b000000-0000-4000-a000-000000000001", "name": "Northwind data platform rollout", "owner_id": "0d000000-0000-4000-a000-000000000001", "step_id": "07000000-0000-4000-a000-000000000001", "revision": 646 } ] } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ Then it sends the deliveries. The cURL tab is the script that sends one delivery to a running receiver: _This sample is REST only._ **REST · cURL** ```bash # One line of JSON, like a delivery; event_time is UTC without an offset. body=$(jq -cn --argjson entity "$record" --arg url "$RECEIVER_URL" --arg space "$SPACE_ID" \ --arg time "$(date -u +%Y-%m-%dT%H:%M:%S)" \ '[{webhook: {url: $url, events: ["Opportunity.Update"]}, event: "Opportunity.Update", event_time: $time, team_space_id: $space, entity: $entity, related_entity: null}]') signature=$(signature_of "$body") verify_signature "$body" "$signature" curl -sS --fail-with-body "$RECEIVER_URL" \ -H "Content-Type: application/json" \ -H "WebHook-Signature: $signature" \ -H "WebHook-Signature-Version: v1" \ -H "Webhook-Delivery-Retry: 0" \ --data-binary "$body" echo ``` **REST · TypeScript** ```ts // A delivery signed the way Coevera signs, then what retries, replays, forgeries and a // deleted webhook look like to the receiver. async function selfTest(url: string): Promise { const record = await findRecord(); const delivery = (eventTime: Date) => JSON.stringify([ { webhook: { id: randomUUID(), url, events: ["Opportunity.Update"] }, event: "Opportunity.Update", event_time: eventTime.toISOString().slice(0, -1), team_space_id: spaceId, entity: record, related_entity: null, }, ]); const send = (body: string, signature: string, retry = 0) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": String(retry) }, body, }).then((r) => r.status); const sign = (body: string) => createHmac("sha256", secret).update(body).digest("hex"); const fresh = delivery(new Date()); const old = delivery(new Date(Date.now() - 48 * 60 * 60 * 1000)); const answers = { first: await send(fresh, sign(fresh)), retry: await send(fresh, sign(fresh), 1), old: await send(old, sign(old)), forged: await send(fresh, "0".repeat(64)), deleted: await fetch(url, { method: "DELETE" }).then((r) => r.status), }; console.log("Answers:", JSON.stringify(answers)); const expected = { first: 200, retry: 200, old: 200, forged: 401, deleted: 200 }; if (JSON.stringify(answers) !== JSON.stringify(expected)) throw new Error(`Expected ${JSON.stringify(expected)}`); if (records.size !== 1 || seen.size !== 1) throw new Error("A retry or an old event was processed."); } ``` **REST · Python** ```python # A delivery signed the way Coevera signs, then what retries, replays, forgeries and a # deleted webhook look like to the receiver. def self_test(url): record = find_record() def delivery(event_time): return json.dumps([{ "webhook": {"id": str(uuid.uuid4()), "url": url, "events": ["Opportunity.Update"]}, "event": "Opportunity.Update", "event_time": event_time.replace(tzinfo=None).isoformat(), "team_space_id": SPACE_ID, "entity": record, "related_entity": None, }]).encode("utf-8") def sign(body): return hmac.new(SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() def send(body, signature, retry=0): headers = { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": str(retry), } status = requests.post(url, data=body, headers=headers, timeout=10).status_code jobs.join() # wait until the receiver has processed it, so the checks below see the result return status now = datetime.now(timezone.utc) fresh = delivery(now) old = delivery(now - timedelta(hours=48)) answers = { "first": send(fresh, sign(fresh)), "retry": send(fresh, sign(fresh), 1), "old": send(old, sign(old)), "forged": send(fresh, "0" * 64), "deleted": requests.delete(url, timeout=10).status_code, } print("Answers:", json.dumps(answers)) expected = {"first": 200, "retry": 200, "old": 200, "forged": 401, "deleted": 200} if answers != expected: sys.exit(f"Expected {json.dumps(expected)}") if len(records) != 1 or len(seen) != 1: sys.exit("A retry or an old event was processed.") ``` **What to check:** the receiver logs `stored revision` for the first delivery, `duplicate, skipped` for the retry and `too old, dropped` for the old one, and stores the record once. Without arguments the program runs the self-test and stops. To run it for real, start it with `--serve` and your secret, then point a webhook at it ([testing locally](/webhooks/testing-locally/)): ```bash WEBHOOK_SECRET="" npx tsx index.ts --serve ``` ## Clean up The receiver writes nothing to Coevera, and the self-test only reads one opportunity. Its store is in memory and is gone when it stops. ## Limits - The seen keys and the records live in memory: a restart forgets them, and two instances do not share them. Use a database for both in production. - The TypeScript version processes events in the request handler after answering, the Python version in one worker thread; a busy receiver needs a job queue. - It does not check `team_space_id`. If one receiver serves several spaces, check it against the space each secret belongs to. - On a `Delete` event the receiver marks the record deleted and keeps its revision. --- title: Pipeline report description: Read every opportunity in a pipeline with filters, expand and cursor pagination together, and total the value and weighted value per step, in REST or GraphQL. canonical: https://developers.coevera.com/recipes/pipeline-report/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Pipeline report **Answer:** Read the pipeline's steps, then the opportunities with filter[step_id] set to those ids (a raw comma is an OR), expand=step,primary_account and load-only, two per page, following page_info.end_cursor (URL-encoded) until has_next_page is false. Total the value per step and weight it by the step's percent. Northwind's sales lead wants the pipeline on one screen: every opportunity in the **Default pipeline**, by step, with its account, and per step the count, the value and the value weighted by the step's probability. The program only reads, so it is safe to run against any space. ## Before you start | | | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | Permissions | **Read Only Access** to **Opportunities** and **Accounts** on the **Records** tab, and to **Pipelines** on the **Administration** tab. | | `validation-level` | Not used: the program only reads. | | Batch behaviour | No batches. | | Context | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`. | The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: REST [`GET /entities/Steps`](/reference/rest/steps/#list), [`GET /entities/Opportunities`](/reference/rest/opportunities/#list); GraphQL [`StepRepository.getByCriteria`](/reference/graphql/step/#q-getByCriteria), [`OpportunityRepository.getByCriteria`](/reference/graphql/opportunity/#q-getByCriteria). ## Set up a helper Every step below uses this setup. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); async function coevera(path: string) { const res = await fetch(`${base}${path}`, { headers: { Authorization: `Basic ${auth}` } }); const body = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${String(body.message).trim()}`); return body; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) def coevera(path, params=None): response = session.get(f"{BASE}{path}", params=params, timeout=30) body = response.json() if not response.ok: sys.exit(f"HTTP {response.status_code} {body.get('name')}: {str(body.get('message')).strip()}") return body ``` **GraphQL · cURL** ```bash COEVERA_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" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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 pipeline's steps Read the steps of the pipeline by its name, in `sort_order`. Each step's `percent` is its probability; the report weights the value with it. The program reads the pipeline called Default pipeline; use the name of yours. **REST · cURL** ```bash # The pipeline's steps in order, with the probability (percent) of each. steps=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Steps" \ --data-urlencode "filter[pipeline.name]=Default pipeline" \ -d order-by=sort_order -d load-only=id,name,percent \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$steps" STEP_IDS=$(jq -er '[.data[].id] | join(",")' <<<"$steps") ``` **REST · TypeScript** ```ts // The pipeline's steps in order, with the probability (percent) of each. const steps = await coevera( `/entities/Steps?${new URLSearchParams({ "filter[pipeline.name]": "Default pipeline", "order-by": "sort_order", "load-only": "id,name,percent" })}`, ); if (!steps.data.length) throw new Error("No pipeline with that name, or it has no steps."); console.log("Steps:", steps.data.map((s: { name: string }) => s.name).join(", ")); ``` **REST · Python** ```python # The pipeline's steps in order, with the probability (percent) of each. steps = coevera( "/entities/Steps", {"filter[pipeline.name]": "Default pipeline", "order-by": "sort_order", "load-only": "id,name,percent"}, )["data"] if not steps: sys.exit("No pipeline with that name, or it has no steps.") print("Steps:", ", ".join(step["name"] for step in steps)) ``` **GraphQL · cURL** ```bash # The pipeline's steps in order, with the probability (percent) of each. steps=$(jq -n '{ query: "{ entities { step { getByCriteria(filter: { pipeline: { name: { eq: [\"Default pipeline\"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name percent } } } } } }" }' | gql) echo "$steps" STEPS=$(jq -c '[.data.entities.step.getByCriteria.edges[].node]' <<<"$steps") ``` **GraphQL · TypeScript** ```ts // The pipeline's steps in order, with the probability (percent) of each. const stepsResult = await gql(`{ entities { step { getByCriteria(filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name percent } } } } } }`); const steps: { id: string; name: string; percent: number }[] = stepsResult.entities.step.getByCriteria.edges.map( (e: { node: { id: string; name: string; percent: number } }) => e.node, ); if (!steps.length) throw new Error("No pipeline with that name, or it has no steps."); console.log("Steps:", steps.map((s) => s.name).join(", ")); ``` **GraphQL · Python** ```python # The pipeline's steps in order, with the probability (percent) of each. steps_result = gql(""" { entities { step { getByCriteria(filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name percent } } } } } } """) steps = [edge["node"] for edge in steps_result["entities"]["step"]["getByCriteria"]["edges"]] if not steps: sys.exit("No pipeline with that name, or it has no steps.") print("Steps:", ", ".join(step["name"] for step in steps)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 3, "page_info": { "start_cursor": "WzAsICIwNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WzIsICIwNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDMiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "id": "07000000-0000-4000-a000-000000000001", "name": "Validate", "percent": 30 }, { "id": "07000000-0000-4000-a000-000000000002", "name": "Offer", "percent": 60 }, { "id": "07000000-0000-4000-a000-000000000003", "name": "Close", "percent": 100 } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "step": { "getByCriteria": { "edges": [ { "node": { "id": "07000000-0000-4000-a000-000000000001", "name": "Validate", "percent": 30 } }, { "node": { "id": "07000000-0000-4000-a000-000000000002", "name": "Offer", "percent": 60 } }, { "node": { "id": "07000000-0000-4000-a000-000000000003", "name": "Close", "percent": 100 } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 2. Read every page One query combines the three tools of a list call: - **Filter.** `filter[step_id]` with the step ids, separated by raw commas: a raw comma is an OR, while `%2C` (what `URLSearchParams` and `requests` send) is a literal comma and matches nothing, so the programs write this filter into the URL themselves. A dotted filter reaches one related record, not its relations: `filter[step.pipeline.name]` answers 400 (code 117), so the report filters on the steps it read in step 1 ([querying](/concepts/querying/#filter)). - **Expand.** `expand=step,primary_account` returns the step and the account as objects, and `load-only` keeps only the fields the report prints, nested ones included (`step.name`). Without `expand`, a relation comes back as its URL. In GraphQL, the query selects `step` and `primaryAccount` directly. - **Paginate.** `first=2` asks for two opportunities per page, so the cursor shows; send up to 100\. While `page_info.has_next_page` is true, send `page_info.end_cursor` as `after`, URL-encoded: a cursor is base64 and a raw `+` in it would arrive as a space. GraphQL takes the cursor as a variable, and calls the fields `pageInfo`, `endCursor` and `hasNextPage`. `order-by=closing_date` (GraphQL `orderBy: [{ closingDate: Asc }]`) keeps the order stable from page to page. These are the two pages of the recorded run: **REST · cURL** ```bash # Opportunities in those steps: a raw comma between the ids is an OR. Step and account come # expanded, and load-only keeps the rows small. Two per page here, so the cursor shows. rows='[]' cursor="" while :; do # --data-urlencode encodes the cursor: a raw "+" in it would arrive as a space. page=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \ -d "filter[step_id]=$STEP_IDS" \ -d order-by=closing_date -d first=2 -d expand=step,primary_account \ -d load-only=id,name,closing_date,status,value,step.name,primary_account.name \ ${cursor:+--data-urlencode "after=$cursor"} \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET") echo "$page" rows=$(jq -c --argjson rows "$rows" '$rows + .data' <<<"$page") next=$(jq -r 'if .page_info.has_next_page then .page_info.end_cursor else "" end' <<<"$page") # An unchanged cursor would ask for the same page again: stop rather than loop forever. if [ -z "$next" ] || [ "$next" = "$cursor" ]; then break; fi cursor="$next" done ``` **REST · TypeScript** ```ts // Opportunities in those steps: a raw comma between the ids is an OR (%2C, which // URLSearchParams sends, would be a literal comma). Step and account come expanded, and // load-only keeps the rows small. Two per page here, so the cursor shows; send up to 100. const stepIds = steps.data.map((s: { id: string }) => s.id).join(","); const query = `filter[step_id]=${stepIds}&order-by=closing_date&first=2&expand=step,primary_account` + "&load-only=id,name,closing_date,status,value,step.name,primary_account.name"; interface Row { name: string; closing_date: string; value: { base_value: number } | null; step: { name: string } | null; primary_account: { name: string } | null; } const rows: Row[] = []; let cursor: string | null = null; do { // encodeURIComponent: a raw "+" in the cursor would arrive as a space. const page = await coevera(`/entities/Opportunities?${query}${cursor ? `&after=${encodeURIComponent(cursor)}` : ""}`); rows.push(...page.data); const next: string | null = page.page_info.has_next_page ? page.page_info.end_cursor : null; // An unchanged cursor would ask for the same page again: stop rather than loop forever. cursor = next === cursor ? null : next; } while (cursor); console.log(rows.length, "opportunities"); ``` **REST · Python** ```python # Opportunities in those steps: a raw comma between the ids is an OR. requests would send the # comma as %2C, a literal comma, so the filter goes into the path by hand. Step and account come # expanded, and load-only keeps the rows small. Two per page here, so the cursor shows. step_ids = ",".join(step["id"] for step in steps) params = { "order-by": "closing_date", "first": 2, "expand": "step,primary_account", "load-only": "id,name,closing_date,status,value,step.name,primary_account.name", } rows = [] cursor = None while True: # requests encodes the cursor: a raw "+" in it would arrive as a space. page = coevera(f"/entities/Opportunities?filter[step_id]={step_ids}", {**params, "after": cursor}) rows.extend(page["data"]) next_cursor = page["page_info"]["end_cursor"] if page["page_info"]["has_next_page"] else None # An unchanged cursor would ask for the same page again: stop rather than loop forever. if not next_cursor or next_cursor == cursor: break cursor = next_cursor print(len(rows), "opportunities") ``` **GraphQL · cURL** ```bash # Opportunities in those steps, with step and account in the same query. Two per page here, # so the cursor shows; ask for up to 100. rows='[]' cursor="" while :; do page=$(jq -n --argjson steps "$STEPS" --arg after "$cursor" '{ query: "query Page($steps: [String]!, $after: String) { entities { opportunity { getByCriteria(first: 2, after: $after, filter: { stepId: { eq: $steps } }, orderBy: [{ closingDate: Asc }]) { totalCount pageInfo { hasNextPage endCursor } edges { node { name closingDate status value { baseValue } step { name } primaryAccount { name } } } } } } }", variables: { steps: [$steps[].id], after: (if $after == "" then null else $after end) } }' | gql) echo "$page" rows=$(jq -c --argjson rows "$rows" '$rows + [.data.entities.opportunity.getByCriteria.edges[].node]' <<<"$page") next=$(jq -r '.data.entities.opportunity.getByCriteria.pageInfo | if .hasNextPage then .endCursor else "" end' <<<"$page") # An unchanged cursor would ask for the same page again: stop rather than loop forever. if [ -z "$next" ] || [ "$next" = "$cursor" ]; then break; fi cursor="$next" done ``` **GraphQL · TypeScript** ```ts // Opportunities in those steps, with step and account in the same query. Two per page here, // so the cursor shows; ask for up to 100. interface Row { name: string; closingDate: string; status: string; value: { baseValue: number } | null; step: { name: string } | null; primaryAccount: { name: string } | null; } const PAGE = `query Page($steps: [String]!, $after: String) { entities { opportunity { getByCriteria(first: 2, after: $after, filter: { stepId: { eq: $steps } }, orderBy: [{ closingDate: Asc }]) { totalCount pageInfo { hasNextPage endCursor } edges { node { name closingDate status value { baseValue } step { name } primaryAccount { name } } } } } } }`; const rows: Row[] = []; let cursor: string | null = null; do { const data = await gql(PAGE, { steps: steps.map((s) => s.id), after: cursor }); const page = data.entities.opportunity.getByCriteria; rows.push(...page.edges.map((e: { node: Row }) => e.node)); const next: string | null = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; // An unchanged cursor would ask for the same page again: stop rather than loop forever. cursor = next === cursor ? null : next; } while (cursor); console.log(rows.length, "opportunities"); ``` **GraphQL · Python** ```python # Opportunities in those steps, with step and account in the same query. Two per page here, # so the cursor shows; ask for up to 100. PAGE = """ query Page($steps: [String]!, $after: String) { entities { opportunity { getByCriteria(first: 2, after: $after, filter: { stepId: { eq: $steps } }, orderBy: [{ closingDate: Asc }]) { totalCount pageInfo { hasNextPage endCursor } edges { node { name closingDate status value { baseValue } step { name } primaryAccount { name } } } } } } } """ rows = [] cursor = None while True: page = gql(PAGE, {"steps": [step["id"] for step in steps], "after": cursor})["entities"]["opportunity"]["getByCriteria"] rows.extend(edge["node"] for edge in page["edges"]) next_cursor = page["pageInfo"]["endCursor"] if page["pageInfo"]["hasNextPage"] else None # An unchanged cursor would ask for the same page again: stop rather than loop forever. if not next_cursor or next_cursor == cursor: break cursor = next_cursor print(len(rows), "opportunities") ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 3, "page_info": { "start_cursor": "WyIyMDI2LTExLTIwIiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMSJd", "end_cursor": "WyIyMDI2LTEyLTE1IiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMiJd", "has_previous_page": false, "has_next_page": true }, "data": [ { "id": "0b000000-0000-4000-a000-000000000001", "step": { "name": "Close" }, "closing_date": "2026-11-20", "name": "Wide World Importers dashboard renewal", "value": { "base_value": 32000, "currency_id": "04000000-0000-4000-a000-000000000001", "value_foreign": 32000 }, "primary_account": { "name": "Wide World Importers" }, "status": 3 }, { "id": "0b000000-0000-4000-a000-000000000002", "step": { "name": "Offer" }, "closing_date": "2026-12-15", "name": "Northwind data platform rollout", "value": { "base_value": 48000, "currency_id": "04000000-0000-4000-a000-000000000001", "value_foreign": 48000 }, "primary_account": { "name": "Northwind Analytics" }, "status": 1 } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opportunity": { "getByCriteria": { "totalCount": 3, "pageInfo": { "hasNextPage": true, "endCursor": "WyIyMDI2LTEyLTE1IiwgIjAwMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMSJd" }, "edges": [ { "node": { "name": "Wide World Importers dashboard renewal", "closingDate": "2026-11-20", "status": "Won", "value": { "baseValue": 32000 }, "step": { "name": "Close" }, "primaryAccount": { "name": "Wide World Importers" } } }, { "node": { "name": "Northwind data platform rollout", "closingDate": "2026-12-15", "status": "Open", "value": { "baseValue": 48000 }, "step": { "name": "Offer" }, "primaryAccount": { "name": "Northwind Analytics" } } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ **Recorded response · REST · 200 OK** ```json { "success": true, "total": 3, "page_info": { "start_cursor": "WyIyMDI3LTAxLTI5IiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMyJd", "end_cursor": "WyIyMDI3LTAxLTI5IiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMyJd", "has_previous_page": true, "has_next_page": false }, "data": [ { "id": "0b000000-0000-4000-a000-000000000003", "step": { "name": "Validate" }, "closing_date": "2027-01-29", "name": "Contoso fleet telemetry pilot", "value": { "base_value": 18500, "currency_id": "04000000-0000-4000-a000-000000000001", "value_foreign": 18500 }, "primary_account": { "name": "Contoso Freight" }, "status": 1 } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "opportunity": { "getByCriteria": { "totalCount": 3, "pageInfo": { "hasNextPage": false, "endCursor": "WyIyMDI3LTAxLTI5IiwgIjAwMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMiJd" }, "edges": [ { "node": { "name": "Contoso fleet telemetry pilot", "closingDate": "2027-01-29", "status": "Open", "value": { "baseValue": 18500 }, "step": { "name": "Validate" }, "primaryAccount": { "name": "Contoso Freight" } } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ `status` is `1` Open, `2` Lost or `3` Won in REST, and `Open`, `Lost` or `Won` in GraphQL: an opportunity in the pipeline's last step is won. ## 3. Build the report No request: the program groups the rows by step, and multiplies each step's value by its `percent`. **REST · cURL** ```bash # Per step: how many, their value, and the value weighted by the step's probability. # An expanded relation is an object; without expand, the API sends its URL instead (hence .name?). jq -r --argjson rows "$rows" '.data[] as $step | [$rows[] | select(.step.name? == $step.name)] as $in | ([$in[] | .value.base_value // 0] | add // 0) as $value | "\($step.name) \($in | length) value \($value) weighted \($value * $step.percent / 100)", ($in[] | " \(.closing_date) \(.name) (\(.primary_account.name? // ""))")' <<<"$steps" ``` **REST · TypeScript** ```ts // Per step: how many, their value, and the value weighted by the step's probability. for (const step of steps.data) { const inStep = rows.filter((o) => o.step?.name === step.name); const value = inStep.reduce((sum, o) => sum + (o.value?.base_value ?? 0), 0); console.log(`${step.name.padEnd(10)} ${String(inStep.length).padStart(3)} value ${value} weighted ${(value * step.percent) / 100}`); for (const o of inStep) console.log(` ${o.closing_date} ${o.name} (${o.primary_account?.name})`); } ``` **REST · Python** ```python # Per step: how many, their value, and the value weighted by the step's probability. def name_of(relation): """An expanded relation is an object; without expand, the API sends its URL instead.""" return relation.get("name") if isinstance(relation, dict) else None for step in steps: in_step = [o for o in rows if name_of(o.get("step")) == step["name"]] value = sum((o.get("value") or {}).get("base_value") or 0 for o in in_step) print(f"{step['name']:<10} {len(in_step):>3} value {value} weighted {value * step['percent'] / 100}") for o in in_step: print(f" {o['closing_date']} {o['name']} ({name_of(o.get('primary_account'))})") ``` **GraphQL · cURL** ```bash # Per step: how many, their value, and the value weighted by the step's probability. jq -rn --argjson steps "$STEPS" --argjson rows "$rows" '$steps[] as $step | [$rows[] | select(.step.name == $step.name)] as $in | ([$in[] | .value.baseValue // 0] | add // 0) as $value | "\($step.name) \($in | length) value \($value) weighted \($value * $step.percent / 100)", ($in[] | " \(.closingDate) \(.name) (\(.primaryAccount.name), \(.status))")' ``` **GraphQL · TypeScript** ```ts // Per step: how many, their value, and the value weighted by the step's probability. for (const step of steps) { const inStep = rows.filter((o) => o.step?.name === step.name); const value = inStep.reduce((sum, o) => sum + (o.value?.baseValue ?? 0), 0); console.log(`${step.name.padEnd(10)} ${String(inStep.length).padStart(3)} value ${value} weighted ${(value * step.percent) / 100}`); for (const o of inStep) console.log(` ${o.closingDate} ${o.name} (${o.primaryAccount?.name}, ${o.status})`); } ``` **GraphQL · Python** ```python # Per step: how many, their value, and the value weighted by the step's probability. for step in steps: in_step = [o for o in rows if (o.get("step") or {}).get("name") == step["name"]] value = sum((o.get("value") or {}).get("baseValue") or 0 for o in in_step) print(f"{step['name']:<10} {len(in_step):>3} value {value} weighted {value * step['percent'] / 100}") for o in in_step: account = (o.get("primaryAccount") or {}).get("name") print(f" {o['closingDate']} {o['name']} ({account}, {o['status']})") ``` In the recorded run, the report reads: ```text Validate 1 value 18500 weighted 5550 Offer 1 value 48000 weighted 28800 Close 1 value 32000 weighted 32000 ``` ## What to check - Step 1 returns the steps in pipeline order. An empty `data` means no pipeline has that name. - Every page answers 200 with at most two rows, the last one with `has_next_page: false` (GraphQL `hasNextPage`), and the rows add up to `total` (GraphQL `totalCount`). - Every row carries `step.name` and `primary_account.name`. A row whose step is a URL means the request lost its `expand`. - Run it twice and you get the same report, unless the pipeline changed in between: the program writes nothing. ## Clean up Nothing to clean up: the program only reads. ## Limits - A page holds at most 100 records, and 30 when you do not set `first`. - The report sums `value.base_value`, in the space's base currency. - In GraphQL, `OpportunityFilterInput` has no `closingDate` field, so a GraphQL report cannot narrow by closing date on the server. REST can, with `filter[closing_date]=2026-11-01,2027-02-28` and `filter-op[closing_date]=between`. --- title: Lead lifecycle (GraphQL) description: Create a lead, qualify it into an opportunity, move the opportunity to the next step and change its owner, with the GraphQL actions that REST does not have. canonical: https://developers.coevera.com/recipes/lead-lifecycle/ section: recipes verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Lead lifecycle (GraphQL) **Answer:** Create the lead with createLead, then call qualifyLead with the lead's id and what an opportunity needs (stepId, closingDate, value): the lead becomes an opportunity with the same id. moveOpportunity changes the step and changeOwnershipOpportunity the owner and unit. These actions exist only in GraphQL; REST has no qualify call. Fabrikam Robotics asks about line-side analytics. Record it as a **lead**, qualify the lead into an opportunity once the interest is real, move the opportunity to the Offer step, and hand it to its owner. You end with one opportunity that carries the lead's id. This recipe is GraphQL only. REST has no call that qualifies a lead. A REST `PATCH` can change an opportunity's `step_id` or `owner_id`, but not with what the GraphQL actions add: `moveOpportunity` takes a memo, activities to complete and quotes to accept or archive, and `changeOwnershipOpportunity` takes `keepOwnerAsEditor` and `transferLinkedRecords`. ## Before you start | | | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Permissions | **Full Access** to **Leads** and **Opportunities**, and **Read Only Access** to **Accounts**, on the **Records** tab; **Read Only Access** to **Pipelines** and **Currencies** on the **Administration** tab. The owner lookup also reads users (`Clients`). | | `validation-level` | The default (full validation). | | Batch behaviour | No batches. | | Context | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`. | The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`. [Authentication](/get-started/authentication/) shows where the credentials come from, and [Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions. The operations this recipe calls, in the API reference: GraphQL [`ClientRepository.getByCriteria`](/reference/graphql/client/#q-getByCriteria), [`AccountRepository.getByCriteria`](/reference/graphql/account/#q-getByCriteria), [`StepRepository.getByCriteria`](/reference/graphql/step/#q-getByCriteria), [`CurrencyRepository.getByCriteria`](/reference/graphql/currency/#q-getByCriteria), [`createLead`](/reference/graphql/lead/#m-createLead), [`qualifyLead`](/reference/graphql/lead/#m-qualifyLead), [`moveOpportunity`](/reference/graphql/opportunity/#m-moveOpportunity), [`changeOwnershipOpportunity`](/reference/graphql/opportunity/#m-changeOwnershipOpportunity), [`LeadRepository.getById`](/reference/graphql/lead/#q-getById), [`OpportunityRepository.getById`](/reference/graphql/opportunity/#q-getById). ## Set up a helper Every step below uses this setup. Export the credentials first: ```bash export COEVERA_API_KEY="" export COEVERA_API_SECRET="" # never commit it ``` _This sample is GraphQL only._ **GraphQL · cURL** ```bash COEVERA_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" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // 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; } ``` **GraphQL · Python** ```python ENDPOINT = ( "https://{region}.api.coevera.com/api/v100/app/space/" "{space_id}/graphql/public" ) session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) 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. Look everything up in one query GraphQL reads several repositories in one request. The program asks for the owner and their sales unit, the account, the pipeline's first step, the Offer step and the base currency. The two step lookups use the same repository, so they carry aliases: `firstStep` and `offerStep`. _This sample is GraphQL only._ **GraphQL · cURL** ```bash # Five lookups in one request, two of them on the same repository under aliases: the owner # and their sales unit, the account, the pipeline's first step, the step to move to, and the # base currency. found=$(jq -n '{ query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name defaultUnitId } } } } account { getByCriteria(first: 1, filter: { email1: { eq: [\"fabrikam@example.com\"] } }) { edges { node { id name } } } } firstStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: [\"Default pipeline\"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name } } } } offerStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: [\"Default pipeline\"] } }, name: { eq: [\"Offer\"] } }) { edges { node { id name } } } } currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } }" }' | gql) echo "$found" OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$found") UNIT_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.defaultUnitId' <<<"$found") ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$found") FIRST_STEP_ID=$(jq -er '.data.entities.firstStep.getByCriteria.edges[0].node.id' <<<"$found") NEXT_STEP_ID=$(jq -er '.data.entities.offerStep.getByCriteria.edges[0].node.id' <<<"$found") CURRENCY_ID=$(jq -er '.data.entities.currency.getByCriteria.edges[0].node.id' <<<"$found") ``` **GraphQL · TypeScript** ```ts // Five lookups in one request, two of them on the same repository under aliases: the owner // and their sales unit, the account, the pipeline's first step, the step to move to, and the // base currency. const found = await gql(`{ entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name defaultUnitId } } } } account { getByCriteria(first: 1, filter: { email1: { eq: ["fabrikam@example.com"] } }) { edges { node { id name } } } } firstStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name } } } } offerStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } }, name: { eq: ["Offer"] } }) { edges { node { id name } } } } currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } }`); const owner = found.entities.client.getByCriteria.edges[0]?.node; const account = found.entities.account.getByCriteria.edges[0]?.node; const firstStep = found.entities.firstStep.getByCriteria.edges[0]?.node; const offerStep = found.entities.offerStep.getByCriteria.edges[0]?.node; const currency = found.entities.currency.getByCriteria.edges[0]?.node; if (!owner || !account || !firstStep || !offerStep || !currency) { throw new Error("A lookup found nothing: check the e-mails, the pipeline and the step names."); } console.log("Owner:", owner.name, "account:", account.name, "steps:", firstStep.name, "then", offerStep.name); ``` **GraphQL · Python** ```python # Five lookups in one request, two of them on the same repository under aliases: the owner # and their sales unit, the account, the pipeline's first step, the step to move to, and the # base currency. found = gql(""" { entities { client { getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) { edges { node { id name defaultUnitId } } } } account { getByCriteria(first: 1, filter: { email1: { eq: ["fabrikam@example.com"] } }) { edges { node { id name } } } } firstStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name } } } } offerStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } }, name: { eq: ["Offer"] } }) { edges { node { id name } } } } currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } } """)["entities"] lookups = {key: found[key]["getByCriteria"]["edges"] for key in ("client", "account", "firstStep", "offerStep", "currency")} if not all(lookups.values()): sys.exit("A lookup found nothing: check the e-mails, the pipeline and the step names.") owner, account, first_step, offer_step, currency = (edges[0]["node"] for edges in lookups.values()) print("Owner:", owner["name"], "account:", account["name"], "steps:", first_step["name"], "then", offer_step["name"]) ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "client": { "getByCriteria": { "edges": [ { "node": { "id": "0d000000-0000-4000-a000-000000000001", "name": "Dana Whitfield", "defaultUnitId": "05000000-0000-4000-a000-000000000001" } } ] } }, "account": { "getByCriteria": { "edges": [ { "node": { "id": "0a000000-0000-4000-a000-000000000001", "name": "Fabrikam Robotics" } } ] } }, "firstStep": { "getByCriteria": { "edges": [ { "node": { "id": "07000000-0000-4000-a000-000000000001", "name": "Validate" } } ] } }, "offerStep": { "getByCriteria": { "edges": [ { "node": { "id": "07000000-0000-4000-a000-000000000002", "name": "Offer" } } ] } }, "currency": { "getByCriteria": { "edges": [ { "node": { "id": "04000000-0000-4000-a000-000000000001", "code": "USD" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 2. Create the lead A lead needs only a `name`. The program gives it an owner and links the account. _This sample is GraphQL only._ **GraphQL · cURL** ```bash lead=$(jq -n --arg owner "$OWNER_ID" --arg account "$ACCOUNT_ID" '{ query: "mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id name status } } }", variables: { input: { name: "Fabrikam line-side analytics", ownerId: $owner, accountRelations: [{ accountId: $account, isPrimary: true }] } } }' | gql) echo "$lead" LEAD_ID=$(jq -er '.data.createLead.lead.id' <<<"$lead") ``` **GraphQL · TypeScript** ```ts const created = await gql( `mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id name status } } }`, { input: { name: "Fabrikam line-side analytics", ownerId: owner.id, accountRelations: [{ accountId: account.id, isPrimary: true }], }, }, ); const leadId: string = created.createLead.lead.id; console.log("Lead:", leadId); ``` **GraphQL · Python** ```python created = gql( """ mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id name status } } } """, { "input": { "name": "Fabrikam line-side analytics", "ownerId": owner["id"], "accountRelations": [{"accountId": account["id"], "isPrimary": True}], } }, ) lead_id = created["createLead"]["lead"]["id"] print("Lead:", lead_id) ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "createLead": { "lead": { "id": "08000000-0000-4000-a000-000000000001", "name": "Fabrikam line-side analytics", "status": "Open" } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 3. Qualify it `qualifyLead` takes the lead's `id` in `entity`, with what an opportunity needs that a lead does not have: a `stepId` in the pipeline, a `closingDate` and a `value`. The result is an opportunity with the **same id**, `wasQualified: true`, and the lead's account. _This sample is GraphQL only._ **GraphQL · cURL** ```bash # Qualifying turns the lead into an opportunity with the same id: give it what an opportunity # needs, a step of the pipeline, a closing date and a value. jq -n --arg id "$LEAD_ID" --arg step "$FIRST_STEP_ID" --arg currency "$CURRENCY_ID" '{ query: "mutation Qualify($input: QualifyLeadInput!) { qualifyLead(input: $input) { result { id status wasQualified step { name } } } }", variables: { input: { entity: { id: $id, stepId: $step, closingDate: "2027-02-26", value: { baseValue: 26000, currencyId: $currency } } } } }' | gql ``` **GraphQL · TypeScript** ```ts // Qualifying turns the lead into an opportunity with the same id: give it what an opportunity // needs, a step of the pipeline, a closing date and a value. const qualified = await gql( `mutation Qualify($input: QualifyLeadInput!) { qualifyLead(input: $input) { result { id status wasQualified step { name } } } }`, { input: { entity: { id: leadId, stepId: firstStep.id, closingDate: "2027-02-26", value: { baseValue: 26000, currencyId: currency.id }, }, }, }, ); console.log("Opportunity:", JSON.stringify(qualified.qualifyLead.result)); ``` **GraphQL · Python** ```python # Qualifying turns the lead into an opportunity with the same id: give it what an opportunity # needs, a step of the pipeline, a closing date and a value. qualified = gql( """ mutation Qualify($input: QualifyLeadInput!) { qualifyLead(input: $input) { result { id status wasQualified step { name } } } } """, { "input": { "entity": { "id": lead_id, "stepId": first_step["id"], "closingDate": "2027-02-26", "value": {"baseValue": 26000, "currencyId": currency["id"]}, } } }, ) print("Opportunity:", json.dumps(qualified["qualifyLead"]["result"])) ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "qualifyLead": { "result": { "id": "08000000-0000-4000-a000-000000000001", "status": "Open", "wasQualified": true, "step": { "name": "Validate" } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 4. Move it to the next step `moveOpportunity` takes the opportunity's `id` and the new `stepId` in `entity`. The same call can add a memo (`memoText`), complete activities (`completeActivityIds`) and accept or archive quotes (`acceptQuoteIds`, `archiveQuoteIds`). _This sample is GraphQL only._ **GraphQL · cURL** ```bash # The move action; moveOpportunity can also add a memo, complete activities and accept or # archive quotes in the same call. jq -n --arg id "$LEAD_ID" --arg step "$NEXT_STEP_ID" '{ query: "mutation Move($input: MoveOpportunityInput!) { moveOpportunity(input: $input) { result { id step { name } } } }", variables: { input: { entity: { id: $id, stepId: $step } } } }' | gql ``` **GraphQL · TypeScript** ```ts // The move action; moveOpportunity can also add a memo, complete activities and accept or // archive quotes in the same call. const moved = await gql( `mutation Move($input: MoveOpportunityInput!) { moveOpportunity(input: $input) { result { id step { name } } } }`, { input: { entity: { id: leadId, stepId: offerStep.id } } }, ); console.log("Moved to:", moved.moveOpportunity.result.step.name); ``` **GraphQL · Python** ```python # The move action; moveOpportunity can also add a memo, complete activities and accept or # archive quotes in the same call. moved = gql( """ mutation Move($input: MoveOpportunityInput!) { moveOpportunity(input: $input) { result { id step { name } } } } """, {"input": {"entity": {"id": lead_id, "stepId": offer_step["id"]}}}, ) print("Moved to:", moved["moveOpportunity"]["result"]["step"]["name"]) ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "moveOpportunity": { "result": { "id": "08000000-0000-4000-a000-000000000001", "step": { "name": "Offer" } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 5. Change the owner `changeOwnershipOpportunity` takes the new `ownerId` and `unitId`, and two choices: whether the old owner stays an editor (`keepOwnerAsEditor`) and whether the linked records move with it (`transferLinkedRecords`). All four are required. The program hands the opportunity to the same rep, so it runs in a space with one user; in your space, pass the new owner and their unit. _This sample is GraphQL only._ **GraphQL · cURL** ```bash # The new owner and their sales unit. The docs sandbox has one user, so the script hands the # opportunity to the same rep; pass the new owner's id and unit in your space. jq -n --arg id "$LEAD_ID" --arg owner "$OWNER_ID" --arg unit "$UNIT_ID" '{ query: "mutation ChangeOwner($input: ChangeOwnershipOpportunityInput!) { changeOwnershipOpportunity(input: $input) { result { id owner { name } } } }", variables: { input: { id: $id, ownerId: $owner, unitId: $unit, keepOwnerAsEditor: false, transferLinkedRecords: false } } }' | gql ``` **GraphQL · TypeScript** ```ts // The new owner and their sales unit. The docs sandbox has one user, so the program hands the // opportunity to the same rep; pass the new owner's id and unit in your space. const changed = await gql( `mutation ChangeOwner($input: ChangeOwnershipOpportunityInput!) { changeOwnershipOpportunity(input: $input) { result { id owner { name } } } }`, { input: { id: leadId, ownerId: owner.id, unitId: owner.defaultUnitId, keepOwnerAsEditor: false, transferLinkedRecords: false } }, ); console.log("Owner:", changed.changeOwnershipOpportunity.result.owner.name); ``` **GraphQL · Python** ```python # The new owner and their sales unit. The docs sandbox has one user, so the program hands the # opportunity to the same rep; pass the new owner's id and unit in your space. changed = gql( """ mutation ChangeOwner($input: ChangeOwnershipOpportunityInput!) { changeOwnershipOpportunity(input: $input) { result { id owner { name } } } } """, { "input": { "id": lead_id, "ownerId": owner["id"], "unitId": owner["defaultUnitId"], "keepOwnerAsEditor": False, "transferLinkedRecords": False, } }, ) print("Owner:", changed["changeOwnershipOpportunity"]["result"]["owner"]["name"]) ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "changeOwnershipOpportunity": { "result": { "id": "08000000-0000-4000-a000-000000000001", "owner": { "name": "Dana Whitfield" } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## 6. Read it back Read the id back as a lead and as an opportunity. The lead is gone (`null`); the opportunity carries its id, the account from the lead, and the step, value and closing date the program set. _This sample is GraphQL only._ **GraphQL · cURL** ```bash # The lead is gone; the opportunity carries its id. jq -n --arg id "$LEAD_ID" '{ query: "query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id } } opportunity { getById(entityId: $id) { id name status wasQualified closingDate value { baseValue } step { name } owner { name } primaryAccount { name } } } } }", variables: { id: $id } }' | gql ``` **GraphQL · TypeScript** ```ts // The lead is gone; the opportunity carries its id. const readBack = await gql( `query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id } } opportunity { getById(entityId: $id) { id name status wasQualified closingDate value { baseValue } step { name } owner { name } primaryAccount { name } } } } }`, { id: leadId }, ); console.log(JSON.stringify(readBack.entities, null, 2)); ``` **GraphQL · Python** ```python # The lead is gone; the opportunity carries its id. read_back = gql( """ query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id } } opportunity { getById(entityId: $id) { id name status wasQualified closingDate value { baseValue } step { name } owner { name } primaryAccount { name } } } } } """, {"id": lead_id}, ) print(json.dumps(read_back["entities"], indent=2)) ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "lead": { "getById": null }, "opportunity": { "getById": { "id": "08000000-0000-4000-a000-000000000001", "name": "Fabrikam line-side analytics", "status": "Open", "wasQualified": true, "closingDate": "2027-02-26", "value": { "baseValue": 26000 }, "step": { "name": "Offer" }, "owner": { "name": "Dana Whitfield" }, "primaryAccount": { "name": "Fabrikam Robotics" } } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## What to check - Step 1 returns one node for each lookup. An empty `edges` names the lookup that found nothing. - Step 3 returns the lead's own `id` with `wasQualified: true`: keep using that id. - Step 4 returns the Offer step, and step 5 the owner. - Step 6 returns `null` for the lead and the opportunity with `status: Open`. - Run the program twice and you get two opportunities. To make it safe to repeat, look the lead up by name before you create it. ## Clean up The program leaves one opportunity in your space. Delete it, then its row in `LeadOpptyAccountRelations` (filter on `lead_oppty_id`): deleting an opportunity leaves the row active. ## Limits - An opportunity in the pipeline's last step is won (`status: Won`), as the [pipeline report](/recipes/pipeline-report/) shows. `archiveOpportunity` archives one; this recipe does not exercise it. - The program qualifies one lead. `bulkQualifyLead` and `bulkMoveOpportunity` exist for many; this recipe does not exercise them. --- title: Webhooks overview description: Get a signed POST when records change instead of polling. Register a webhook through the API, choose from 138 events, and verify every delivery. canonical: https://developers.coevera.com/webhooks/overview/ section: webhooks verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Webhooks overview **Answer:** Create a webhook with POST /entities/Webhooks: url, events and insecure_ssl are required, and a signature UUID makes Coevera sign every delivery. Deliveries are POSTs of a JSON array of up to 100 events, signed in WebHook-Signature with HMAC-SHA256 of the raw body. Answer any 2xx within 10 seconds; failed deliveries are retried for about 24 hours. A webhook tells Coevera to call your URL when records change, so you do not have to poll the API. You register it through the API; the CRM has no screen for webhooks. ## How webhooks work 1. A record changes in your space. 2. Coevera POSTs a JSON array of events to your URL, signed with your webhook's secret. 3. Your receiver checks the signature against the raw body. 4. Your receiver answers 2xx within 10 seconds, then processes the events. ## Before you begin - An API application whose permissions allow **Write** on **Webhooks** (under **Misc**; see [permissions and scopes](/get-started/permissions-and-scopes/)), and its credentials in `COEVERA_API_KEY` and `COEVERA_API_SECRET` (see [authentication](/get-started/authentication/)). **Read** is enough to list webhooks. - A receiver reachable from the internet over HTTPS. To build one, follow the [webhook receiver recipe](/recipes/webhook-receiver/); to try it on your machine first, see [testing locally](/webhooks/testing-locally/). - Your region and space ID in the bar at the top of the page; the samples fill them in. The samples below share this setup: the base URL, the credentials and your receiver's URL. **REST · cURL** ```bash COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" # Your receiver: Coevera POSTs every delivery to this URL. RECEIVER_URL="https://hooks.example.com/coevera" ``` **REST · TypeScript** ```ts const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // Your receiver: Coevera POSTs every delivery to this URL. const receiverUrl = "https://hooks.example.com/coevera"; async function coevera(method: string, path: string, body?: unknown) { const res = await fetch(`${base}${path}`, { method, headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`HTTP ${res.status} ${json.name}: ${json.message}`); return json; } ``` **REST · Python** ```python BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) # Your receiver: Coevera POSTs every delivery to this URL. RECEIVER_URL = "https://hooks.example.com/coevera" 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')}: {body.get('message')}") return body ``` **GraphQL · cURL** ```bash COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" # Your receiver: Coevera POSTs every delivery to this URL. RECEIVER_URL="https://hooks.example.com/coevera" # 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" } ``` **GraphQL · TypeScript** ```ts const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`; const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64"); // Your receiver: Coevera POSTs every delivery to this URL. const receiverUrl = "https://hooks.example.com/coevera"; // gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200: check both. 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; } ``` **GraphQL · Python** ```python ENDPOINT = "https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public" session = requests.Session() session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"]) # Your receiver: Coevera POSTs every delivery to this URL. RECEIVER_URL = "https://hooks.example.com/coevera" 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"] ``` ## Create a webhook Send the URL, the events and `insecure_ssl`, which are required, and a `signature`: a UUID you generate and keep as your receiver's secret. In GraphQL, events are written without the dot (`OpportunityWon`) and `options` is a JSON string. Every field and operation of the entity is on the [Webhooks reference page](/reference/rest/webhooks/#create). **REST · cURL** ```bash # The signing secret: a random UUID. Keep it as WEBHOOK_SECRET in your receiver. SECRET=$(od -An -tx1 -N16 /dev/urandom | tr -d ' \n' | sed -E 's/^(.{8})(.{4}).(.{3}).(.{3})(.{12})$/\1-\2-4\3-a\4-\5/') webhook=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Webhooks" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- < **Warning:** Every read of a webhook returns its `signature`, the secret your receiver verifies deliveries > with. Keep webhook responses out of logs, and give the **Webhooks** permission only to the > applications that manage them. A webhook registered with an API application's credentials has no `client_id`. Its deliveries include every record the change touched, private records too. ## The payload Each delivery is a `POST` to your URL with `Content-Type: application/json` and a body that is a JSON array: one chunk of up to `chunk_size` events, 100 by default. Each event carries (from the code): | Field | What it holds | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `webhook` | The webhook's registration, such as its `id`, `url`, `events` and `options`, without its `signature` | | `event` | The event that fired, such as `Opportunity.Won` | | `event_time` | When the change was saved: UTC, ISO 8601 without an offset | | `team_space_id` | Your space ID | | `entity` | The record as the REST API returns it, shaped by `load-only` and `expand` in the options ([querying](/concepts/querying/#load-only-and-expand)) | | `related_entity` | The document for a `DocumentLinked` event, otherwise `null` | The record is read when the delivery is built, not when it was written, and a retry sends the same body again. The delivery has no event or delivery ID of its own: deduplicate on `event`, `entity.id`, `entity.revision` and `event_time`, as the [security page](/webhooks/security-and-signatures/#replays-and-duplicates) explains. ## Verify the signature When the webhook has a `signature`, every delivery carries these headers: | Header | Value | | --------------------------- | -------------------------------------------------------------------------------- | | `WebHook-Signature` | Lowercase hex HMAC-SHA256 of the exact request body, keyed with your `signature` | | `WebHook-Signature-Version` | `v1` | | `Webhook-Delivery-Retry` | The attempt: `0` on the first try, up to `10` | | `client-request-id` | The `Client-Request-Id` of the API call that caused the change, if it sent one | HTTP header names are case-insensitive, so read them in any case. To check a delivery without writing code, paste it into the [signature checker](/webhooks/security-and-signatures/#signature-checker); it runs in your browser. ## Delivery and retries | Rule | Value | | ---------------- | ---------------------------------------------------------------------------- | | Success | Any 2xx within 10 seconds | | Retries | Up to 10 retries, 11 attempts, over about 24 hours, with exponential backoff | | Disabled at once | Your endpoint answers 403 or 410 | | Disabled later | Retries run out, or a 404 on the last attempt | | Order | Not guaranteed; each chunk retries on its own | | On delete | Deleting a webhook sends `DELETE` to its URL; accept it | A disabled webhook disappears without notice. The details, and what to do about it, are on [delivery and retries](/webhooks/delivery-and-retries/). ## Avoid loops If your integration writes back to Coevera, a webhook would report your own writes to you. Send the header `WebHook-Skip-Key: northwind-sync` on those API calls, and list the same key in the webhook's `options.skip_keys`: the webhook does not fire for those writes. The update below adds the key. ## Update, list and delete Update with [`PATCH`](/reference/rest/webhooks/#update) (REST) or `updateWebhook` (GraphQL). The `events` and `options` you send replace the webhook's, so send the whole list and the whole object: **REST · cURL** ```bash # The events and options you send replace the webhook's: send the whole list and object. curl -sS --fail-with-body -X PATCH "$COEVERA_BASE/entities/Webhooks/$WEBHOOK_ID" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- <) => ({ id, url, events, options })); console.log(JSON.stringify(shown, null, 2)); ``` **REST · Python** ```python # Read webhooks without load-only: with it, options comes back empty. Every read also # returns the signature, so keep these responses out of logs. webhooks = coevera("GET", "/entities/Webhooks") shown = [{key: w[key] for key in ("id", "url", "events", "options")} for w in webhooks["data"]] print(json.dumps(shown, indent=2)) ``` **GraphQL · cURL** ```bash jq -n '{ query: "{ entities { webhook { getByCriteria(first: 10) { totalCount edges { node { id url events options } } } } } }" }' | gql ``` **GraphQL · TypeScript** ```ts const list = await gql(`{ entities { webhook { getByCriteria(first: 10) { totalCount edges { node { id url events options } } } } } }`); console.log(JSON.stringify(list.entities.webhook.getByCriteria, null, 2)); ``` **GraphQL · Python** ```python listed = gql(""" { entities { webhook { getByCriteria(first: 10) { totalCount edges { node { id url events options } } } } } } """) print(json.dumps(listed["entities"]["webhook"]["getByCriteria"], indent=2)) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 1, "page_info": { "start_cursor": "WyIxZTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "end_cursor": "WyIxZTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==", "has_previous_page": false, "has_next_page": false }, "data": [ { "is_delete_protected": false, "id": "1e000000-0000-4000-a000-000000000001", "is_deleted": false, "modified": "2026-10-15 09:00:00.145616+00:00", "created": "2026-10-15 09:00:00.000035+00:00", "application": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Applications/00000000-0000-4000-a000-000000000001", "client": null, "application_id": "00000000-0000-4000-a000-000000000001", "client_id": null, "insecure_ssl": false, "options": { "entity": { "Opportunity": { "load-only": [ "id", "name", "owner_id", "step_id", "value", "revision" ] } }, "skip_keys": [ "northwind-sync" ], "chunk_size": 50 }, "signature": "1e000000-0000-4000-a000-000000000002", "url": "https://hooks.example.com/coevera", "events": [ "Opportunity.Move", "Opportunity.Lost", "Opportunity.Won" ] } ] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "webhook": { "getByCriteria": { "totalCount": 1, "edges": [ { "node": { "id": "1e000000-0000-4000-a000-000000000001", "url": "https://hooks.example.com/coevera", "events": [ "OpportunityWon", "OpportunityLost", "OpportunityMove" ], "options": "{\"entity\": {\"Opportunity\": {\"load-only\": [\"id\", \"name\", \"owner_id\", \"step_id\", \"value\", \"revision\"]}}, \"skip_keys\": [\"northwind-sync\"], \"chunk_size\": 50}" } } ] } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ Delete it. Coevera then sends `DELETE` to the webhook's URL, so your receiver should answer it with a 2xx: **REST · cURL** ```bash curl -sS --fail-with-body -X DELETE "$COEVERA_BASE/entities/Webhooks/$WEBHOOK_ID" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" echo ``` **REST · TypeScript** ```ts await coevera("DELETE", `/entities/Webhooks/${webhookId}`); ``` **REST · Python** ```python coevera("DELETE", f"/entities/Webhooks/{webhook_id}") ``` **GraphQL · cURL** ```bash jq -n --arg id "$WEBHOOK_ID" '{ query: "mutation DeleteWebhook($input: DeleteWebhookInput!) { deleteWebhook(input: $input) { webhook { id isDeleted } } }", variables: { input: { id: $id } } }' | gql ``` **GraphQL · TypeScript** ```ts await gql( `mutation DeleteWebhook($input: DeleteWebhookInput!) { deleteWebhook(input: $input) { webhook { id isDeleted } } }`, { input: { id: webhookId } }, ); ``` **GraphQL · Python** ```python gql( """ mutation DeleteWebhook($input: DeleteWebhookInput!) { deleteWebhook(input: $input) { webhook { id isDeleted } } } """, {"input": {"id": webhook_id}}, ) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "data": "1e000000-0000-4000-a000-000000000001" } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "deleteWebhook": { "webhook": { "id": "1e000000-0000-4000-a000-000000000001", "isDeleted": true } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ The API soft-deletes: the webhook leaves the list, and `include-deleted=true` still shows it ([querying](/concepts/querying/#load-only-and-expand)). **REST · cURL** ```bash # A deleted webhook is gone from the list (total: 0); include-deleted=true would still show it. curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Webhooks" \ --data-urlencode "filter[id]=$WEBHOOK_ID" \ -d load-only=id \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" echo ``` **REST · TypeScript** ```ts // A deleted webhook is gone from the list (total: 0); include-deleted=true would still show it. const left = await coevera("GET", `/entities/Webhooks?filter[id]=${encodeURIComponent(webhookId)}&load-only=id`); console.log("Webhooks listed with this id:", left.total); ``` **REST · Python** ```python # A deleted webhook is gone from the list (total: 0); include-deleted=true would still show it. left = coevera("GET", "/entities/Webhooks", params={"filter[id]": webhook_id, "load-only": "id"}) print("Webhooks listed with this id:", left.get("total")) ``` **GraphQL · cURL** ```bash # A deleted webhook is gone from the list (totalCount: 0); includeDeleted: true would still show it. jq -n --arg id "$WEBHOOK_ID" '{ query: "query Left($id: String!) { entities { webhook { getByCriteria(filter: { id: { eq: [$id] } }) { totalCount } } } }", variables: { id: $id } }' | gql ``` **GraphQL · TypeScript** ```ts // A deleted webhook is gone from the list (totalCount: 0); includeDeleted: true would still show it. const left = await gql( `query Left($id: String!) { entities { webhook { getByCriteria(filter: { id: { eq: [$id] } }) { totalCount } } } }`, { id: webhookId }, ); console.log("Webhooks listed with this id:", left.entities.webhook.getByCriteria.totalCount); ``` **GraphQL · Python** ```python # A deleted webhook is gone from the list (totalCount: 0); includeDeleted: true would still show it. left = gql( """ query Left($id: String!) { entities { webhook { getByCriteria(filter: { id: { eq: [$id] } }) { totalCount } } } } """, {"id": webhook_id}, ) print("Webhooks listed with this id:", left["entities"]["webhook"]["getByCriteria"]["totalCount"]) ``` **Recorded response · REST · 200 OK** ```json { "success": true, "total": 0, "page_info": { "start_cursor": null, "end_cursor": null, "has_previous_page": false, "has_next_page": false }, "data": [] } ``` **Recorded response · GraphQL · 200 OK** ```json { "data": { "entities": { "webhook": { "getByCriteria": { "totalCount": 0 } } } } } ``` _Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._ ## Limits - Webhooks are managed through the API only; the CRM has no screen for them. - At most 100 events per delivery (`chunk_size`, 1 to 100). - There is no test event, delivery log or replay in Coevera; test with your own deliveries ([testing locally](/webhooks/testing-locally/)). - There is no notification when a webhook is disabled. - The Automatizer **Send Webhook** action is a separate system with its own rules; see [API webhooks vs Automatizer webhooks](/webhooks/api-vs-automatizer-webhooks/). ## Next steps - [Events catalogue](/webhooks/events/): every event you can subscribe to. - [Security and signatures](/webhooks/security-and-signatures/): verify deliveries and handle replays. - [Build a webhook receiver](/recipes/webhook-receiver/): a tested receiver in TypeScript and Python. --- title: Webhook events catalogue description: "Every event a webhook can subscribe to, generated from the API's own list: 138 events across 26 entities, with the GraphQL name of each." canonical: https://developers.coevera.com/webhooks/events/ section: webhooks verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Webhook events catalogue **Answer:** A webhook subscribes to any of 138 events: the global *, and for each of 26 entities its all-events form such as Opportunity.*, plus Create, Update, Delete and business actions such as Opportunity.Won. GraphQL writes them without the dot: OpportunityWon, OpportunityAll, All. Custom entities use CustomEntity.*; tags, fields, pipelines, users and documents have no events. The list below is generated from the API's own event list in the published OpenAPI spec, so it holds every event there is. Subscribe to events in a webhook's `events`; in GraphQL use the name in the last column. ## Events Filter by entity, or search by name: | Event | Fires when | GraphQL | | ---------------------------------- | ---------------------------------------------------- | --------------------------------- | | `*` | Any event of any entity | `All` | | `Account.*` | Any event for this entity | `AccountAll` | | `Account.Create` | A record is created | `AccountCreate` | | `Account.Delete` | A record is deleted (soft delete) | `AccountDelete` | | `Account.DocumentLinked` | A document is attached; it arrives as related_entity | `AccountDocumentLinked` | | `Account.OwnerChanged` | The owner changes | `AccountOwnerChanged` | | `Account.Update` | A record is updated | `AccountUpdate` | | `ActivityRelation.*` | Any event for this entity | `ActivityRelationAll` | | `ActivityRelation.Create` | A record is created | `ActivityRelationCreate` | | `ActivityRelation.Delete` | A record is deleted (soft delete) | `ActivityRelationDelete` | | `ActivityRelation.Update` | A record is updated | `ActivityRelationUpdate` | | `Appointment.*` | Any event for this entity | `AppointmentAll` | | `Appointment.Comment` | A comment is added | `AppointmentComment` | | `Appointment.Create` | A record is created | `AppointmentCreate` | | `Appointment.Delete` | A record is deleted (soft delete) | `AppointmentDelete` | | `Appointment.DocumentLinked` | A document is attached; it arrives as related_entity | `AppointmentDocumentLinked` | | `Appointment.OwnerChanged` | The owner changes | `AppointmentOwnerChanged` | | `Appointment.Update` | A record is updated | `AppointmentUpdate` | | `Contact.*` | Any event for this entity | `ContactAll` | | `Contact.Create` | A record is created | `ContactCreate` | | `Contact.Delete` | A record is deleted (soft delete) | `ContactDelete` | | `Contact.DocumentLinked` | A document is attached; it arrives as related_entity | `ContactDocumentLinked` | | `Contact.OwnerChanged` | The owner changes | `ContactOwnerChanged` | | `Contact.Update` | A record is updated | `ContactUpdate` | | `ContactAccountRelation.*` | Any event for this entity | `ContactAccountRelationAll` | | `ContactAccountRelation.Create` | A record is created | `ContactAccountRelationCreate` | | `ContactAccountRelation.Delete` | A record is deleted (soft delete) | `ContactAccountRelationDelete` | | `ContactAccountRelation.Update` | A record is updated | `ContactAccountRelationUpdate` | | `Currency.*` | Any event for this entity | `CurrencyAll` | | `Currency.Create` | A record is created | `CurrencyCreate` | | `Currency.Delete` | A record is deleted (soft delete) | `CurrencyDelete` | | `Currency.Update` | A record is updated | `CurrencyUpdate` | | `CurrencyExchangeRate.*` | Any event for this entity | `CurrencyExchangeRateAll` | | `CurrencyExchangeRate.Create` | A record is created | `CurrencyExchangeRateCreate` | | `CurrencyExchangeRate.Delete` | A record is deleted (soft delete) | `CurrencyExchangeRateDelete` | | `CurrencyExchangeRate.Update` | A record is updated | `CurrencyExchangeRateUpdate` | | `CurrencyExchangeRatesList.*` | Any event for this entity | `CurrencyExchangeRatesListAll` | | `CurrencyExchangeRatesList.Create` | A record is created | `CurrencyExchangeRatesListCreate` | | `CurrencyExchangeRatesList.Delete` | A record is deleted (soft delete) | `CurrencyExchangeRatesListDelete` | | `CurrencyExchangeRatesList.Update` | A record is updated | `CurrencyExchangeRatesListUpdate` | | `CustomEntity.*` | Any event for this entity | `CustomEntityAll` | | `CustomEntity.Create` | A record is created | `CustomEntityCreate` | | `CustomEntity.Delete` | A record is deleted (soft delete) | `CustomEntityDelete` | | `CustomEntity.DocumentLinked` | A document is attached; it arrives as related_entity | `CustomEntityDocumentLinked` | | `CustomEntity.OwnerChanged` | The owner changes | `CustomEntityOwnerChanged` | | `CustomEntity.Update` | A record is updated | `CustomEntityUpdate` | | `Email.*` | Any event for this entity | `EmailAll` | | `Email.Create` | A record is created | `EmailCreate` | | `Email.Delete` | A record is deleted (soft delete) | `EmailDelete` | | `Email.DocumentLinked` | A document is attached; it arrives as related_entity | `EmailDocumentLinked` | | `Email.Update` | A record is updated | `EmailUpdate` | | `Lead.*` | Any event for this entity | `LeadAll` | | `Lead.BackToLead` | An opportunity is turned back into a lead | `LeadBackToLead` | | `Lead.Create` | A record is created | `LeadCreate` | | `Lead.Delete` | A record is deleted (soft delete) | `LeadDelete` | | `Lead.DocumentLinked` | A document is attached; it arrives as related_entity | `LeadDocumentLinked` | | `Lead.Lost` | Marked as lost | `LeadLost` | | `Lead.OwnerChanged` | The owner changes | `LeadOwnerChanged` | | `Lead.Update` | A record is updated | `LeadUpdate` | | `LeadOpptyAccountRelation.*` | Any event for this entity | `LeadOpptyAccountRelationAll` | | `LeadOpptyAccountRelation.Create` | A record is created | `LeadOpptyAccountRelationCreate` | | `LeadOpptyAccountRelation.Delete` | A record is deleted (soft delete) | `LeadOpptyAccountRelationDelete` | | `LeadOpptyAccountRelation.Update` | A record is updated | `LeadOpptyAccountRelationUpdate` | | `LeadOpptyContactRelation.*` | Any event for this entity | `LeadOpptyContactRelationAll` | | `LeadOpptyContactRelation.Create` | A record is created | `LeadOpptyContactRelationCreate` | | `LeadOpptyContactRelation.Delete` | A record is deleted (soft delete) | `LeadOpptyContactRelationDelete` | | `LeadOpptyContactRelation.Update` | A record is updated | `LeadOpptyContactRelationUpdate` | | `Memo.*` | Any event for this entity | `MemoAll` | | `Memo.Comment` | A comment is added | `MemoComment` | | `Memo.Create` | A record is created | `MemoCreate` | | `Memo.Delete` | A record is deleted (soft delete) | `MemoDelete` | | `Memo.DocumentLinked` | A document is attached; it arrives as related_entity | `MemoDocumentLinked` | | `Memo.Update` | A record is updated | `MemoUpdate` | | `Message.*` | Any event for this entity | `MessageAll` | | `Message.Create` | A record is created | `MessageCreate` | | `Message.Delete` | A record is deleted (soft delete) | `MessageDelete` | | `Message.DocumentLinked` | A document is attached; it arrives as related_entity | `MessageDocumentLinked` | | `Message.Update` | A record is updated | `MessageUpdate` | | `Note.*` | Any event for this entity | `NoteAll` | | `Note.Create` | A record is created | `NoteCreate` | | `Note.Delete` | A record is deleted (soft delete) | `NoteDelete` | | `Note.Update` | A record is updated | `NoteUpdate` | | `Opportunity.*` | Any event for this entity | `OpportunityAll` | | `Opportunity.Create` | A record is created | `OpportunityCreate` | | `Opportunity.Delete` | A record is deleted (soft delete) | `OpportunityDelete` | | `Opportunity.DocumentLinked` | A document is attached; it arrives as related_entity | `OpportunityDocumentLinked` | | `Opportunity.Lost` | Marked as lost | `OpportunityLost` | | `Opportunity.Move` | Moves to another step, including when it is won | `OpportunityMove` | | `Opportunity.OwnerChanged` | The owner changes | `OpportunityOwnerChanged` | | `Opportunity.Qualify` | A lead is qualified into this opportunity | `OpportunityQualify` | | `Opportunity.Update` | A record is updated | `OpportunityUpdate` | | `Opportunity.Won` | Marked as won | `OpportunityWon` | | `OpptyProductRelation.*` | Any event for this entity | `OpptyProductRelationAll` | | `OpptyProductRelation.Create` | A record is created | `OpptyProductRelationCreate` | | `OpptyProductRelation.Delete` | A record is deleted (soft delete) | `OpptyProductRelationDelete` | | `OpptyProductRelation.Update` | A record is updated | `OpptyProductRelationUpdate` | | `Product.*` | Any event for this entity | `ProductAll` | | `Product.Create` | A record is created | `ProductCreate` | | `Product.Delete` | A record is deleted (soft delete) | `ProductDelete` | | `Product.Update` | A record is updated | `ProductUpdate` | | `ProductPriceList.*` | Any event for this entity | `ProductPriceListAll` | | `ProductPriceList.Create` | A record is created | `ProductPriceListCreate` | | `ProductPriceList.Delete` | A record is deleted (soft delete) | `ProductPriceListDelete` | | `ProductPriceList.Update` | A record is updated | `ProductPriceListUpdate` | | `ProductPriceListPrice.*` | Any event for this entity | `ProductPriceListPriceAll` | | `ProductPriceListPrice.Create` | A record is created | `ProductPriceListPriceCreate` | | `ProductPriceListPrice.Delete` | A record is deleted (soft delete) | `ProductPriceListPriceDelete` | | `ProductPriceListPrice.Update` | A record is updated | `ProductPriceListPriceUpdate` | | `Project.*` | Any event for this entity | `ProjectAll` | | `Project.Create` | A record is created | `ProjectCreate` | | `Project.Delete` | A record is deleted (soft delete) | `ProjectDelete` | | `Project.DocumentLinked` | A document is attached; it arrives as related_entity | `ProjectDocumentLinked` | | `Project.OwnerChanged` | The owner changes | `ProjectOwnerChanged` | | `Project.Update` | A record is updated | `ProjectUpdate` | | `Quote.*` | Any event for this entity | `QuoteAll` | | `Quote.Create` | A record is created | `QuoteCreate` | | `Quote.Delete` | A record is deleted (soft delete) | `QuoteDelete` | | `Quote.DocumentLinked` | A document is attached; it arrives as related_entity | `QuoteDocumentLinked` | | `Quote.Lost` | Marked as lost | `QuoteLost` | | `Quote.Move` | Moves to another step, including when it is won | `QuoteMove` | | `Quote.OwnerChanged` | The owner changes | `QuoteOwnerChanged` | | `Quote.Update` | A record is updated | `QuoteUpdate` | | `Quote.Won` | Marked as won | `QuoteWon` | | `QuoteAccountRelation.*` | Any event for this entity | `QuoteAccountRelationAll` | | `QuoteAccountRelation.Create` | A record is created | `QuoteAccountRelationCreate` | | `QuoteAccountRelation.Delete` | A record is deleted (soft delete) | `QuoteAccountRelationDelete` | | `QuoteAccountRelation.Update` | A record is updated | `QuoteAccountRelationUpdate` | | `QuoteContactRelation.*` | Any event for this entity | `QuoteContactRelationAll` | | `QuoteContactRelation.Create` | A record is created | `QuoteContactRelationCreate` | | `QuoteContactRelation.Delete` | A record is deleted (soft delete) | `QuoteContactRelationDelete` | | `QuoteContactRelation.Update` | A record is updated | `QuoteContactRelationUpdate` | | `Task.*` | Any event for this entity | `TaskAll` | | `Task.Comment` | A comment is added | `TaskComment` | | `Task.Create` | A record is created | `TaskCreate` | | `Task.Delete` | A record is deleted (soft delete) | `TaskDelete` | | `Task.DocumentLinked` | A document is attached; it arrives as related_entity | `TaskDocumentLinked` | | `Task.OwnerChanged` | The owner changes | `TaskOwnerChanged` | | `Task.Update` | A record is updated | `TaskUpdate` | ## How the names work - `*` subscribes to every event of every entity below; `Opportunity.*` to every event of one entity. - Create, Update and Delete exist for every entity. A delete is a soft delete: the record can be restored. - Winning an opportunity or a quote also sends its `Move` event, so a webhook on both receives two events for one win. - A webhook can also narrow an event to some records or to changes of some fields: [filtering and options](/webhooks/filtering-and-options/). ## Custom entities Subscribe to `CustomEntity.*` or one of its actions, not to `CE_YourEntity.*`. The event covers every custom entity in the space; narrow it with `options.custom_entity_api_name`, a list of the custom entities' API names, such as `["CE_Shipment"]`. ## Documents A document attached to a record arrives as that record's `DocumentLinked` event, such as `Opportunity.DocumentLinked`, with the document in `related_entity`. Documents have no events of their own. ## Entities without events > **No events for these:** Tags, fields, forms, pipelines, steps, sales units, users, roles, online forms and documents send > no events. To follow changes to them, read them again on a schedule. ## Limits - The catalogue lists the events of the API release the spec was published for. Releases that add events list them in the [API changelog](/changelog/api/). - The fields every event carries are on the [overview](/webhooks/overview/#the-payload). --- title: Filtering and options description: Narrow and shape what a webhook delivers with its options, from filter and on-field-change to load-only, chunk_size and skip_keys. canonical: https://developers.coevera.com/webhooks/filtering-and-options/ section: webhooks verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Filtering and options **Answer:** A webhook's options object narrows and shapes deliveries. Per entity, filter and filter-op choose which records are delivered, on-field-change delivers only when listed fields changed, and load-only and expand shape the record. At the top level, chunk_size (1 to 100), chunk_delay, skip_keys and custom_entity_api_name apply. Option keys use load-only with a hyphen; load_only is refused. `options` is optional: without it, a webhook delivers every record of the events it subscribes to, as the REST API returns it. The API checks every key when you create or update a webhook and refuses one it does not know with a 400, naming the key ([errors](/concepts/errors/)). The options follow the REST API's list parameters, which [querying](/concepts/querying/) describes, and are a field of the [Webhooks entity](/reference/rest/webhooks/#fields). This update sets a per-entity `load-only` and two top-level options, `chunk_size` and `skip_keys`: **REST · cURL** ```bash # The events and options you send replace the webhook's: send the whole list and object. curl -sS --fail-with-body -X PATCH "$COEVERA_BASE/entities/Webhooks/$WEBHOOK_ID" \ -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \ -H "Content-Type: application/json" \ -d @- <`, five keys are accepted, in the REST API's names (snake case for fields, a hyphen in `load-only`, `filter-op` and `on-field-change`): | Key | What it does | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `filter` | Deliver only the records that match, in the REST API's [filter](/concepts/querying/#filter) form: `{"name": "Northwind"}`, or a list of values for any of them. A field the entity does not have is refused. | | `filter-op` | The operator per filtered field, such as `{"name": "contains"}`; `eq` by default. The [operators](/concepts/querying/#operators) are those of the REST API. | | `on-field-change` | Deliver a record only if one of these fields changed in that change: `["step_id", "value"]`. An unknown field name is refused. | | `load-only` | The fields the record in the delivery carries. Keep `id` and `revision` if you deduplicate on them. | | `expand` | Related records to include in full, such as `["owner"]`. | > **Warning:** `load-only` takes a hyphen. `load_only`, which some REST query parameters tolerate, is refused > here with a 400. ### on-field-change narrows every event `on-field-change` applies to every event of that entity the webhook subscribes to, not only to `Update`: a `Create`, a `Won` or a `Delete` is delivered only if one of the listed fields changed in that same change. Subscribe to `Opportunity.Won` with `on-field-change: ["value"]`, and a win that leaves the value alone is not delivered. Use a second webhook for events that must always arrive. It works for these entities: Account, Contact, Lead, Opportunity, Task, Appointment, product line items (`OpptyProductRelation`), Project, Quote, Email, and custom entities. `OnlineForm` is accepted but has no events. Any other entity, such as `Product`, `Memo` or `Note`, is refused. ## Reading and changing options - An update replaces `options` as a whole: send every key you want to keep, as the sample above does. - List webhooks without `load-only`: with it, `options` comes back as `{}`. - In GraphQL, `options` is a JSON string, in the input and in the response. - `options: null` is refused; send `{}` to clear them. ## Limits - `related_entity` takes only `expand`; there is no `load-only` or `filter` for the document. - The filter applies to the record as it is when the delivery is built, not when it changed. - Records the webhook's owner cannot read are left out of deliveries. A webhook registered with an API application's credentials has no owner, so it delivers every record. --- title: Security and signatures description: Prove that a delivery came from Coevera and was not changed on the way. Check a signature in your browser, then do the same in your receiver. canonical: https://developers.coevera.com/webhooks/security-and-signatures/ section: webhooks verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # Security and signatures **Answer:** WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's signature value, sent with WebHook-Signature-Version: v1. Compute it over the bytes you received, before parsing, and compare in constant time. The signature has no timestamp or nonce, so also drop old events and deduplicate on event, entity.id, entity.revision and event_time. A webhook signs its deliveries when it has a `signature`: a UUID you choose when you create it. ## The v1 scheme - **Key:** the webhook's `signature` value, as UTF-8 bytes. The API stores it in lowercase and returns it that way: use it exactly as the API returns it. - **Message:** the exact bytes of the request body. A delivery body is one line of JSON with a space after every comma and colon, and non-ASCII characters escaped, so parsing it and serialising it again changes the bytes and the signature. - **Output:** lowercase hex, 64 characters, in the `WebHook-Signature` header, with `WebHook-Signature-Version: v1`. - **Opt-in:** a webhook without a `signature` sends no signature header, and its deliveries carry no authentication at all. Set one on every webhook. ## Signature checker Paste a delivery you received, the webhook's `signature` value and the `WebHook-Signature` header. The checker computes the signature with your browser's Web Crypto and compares the two. What you paste is not sent anywhere and not stored: reload the page and the fields are empty. **Signature checker.** The web page has an in-browser checker: paste the raw body, the webhook's `signature` value and the received `WebHook-Signature` header, and it computes the signature with Web Crypto; nothing leaves the page. To check a delivery yourself, compute HMAC-SHA256 over the exact request body bytes, keyed with the `signature` value as UTF-8, write it as lowercase hex (64 characters), and compare it with the `WebHook-Signature` header in constant time. `WebHook-Signature-Version` is `v1`. **TypeScript** ```ts // WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the // webhook's signature value. Check the bytes as received, before parsing, in constant time. function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false; const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex")); const received = Buffer.from(header.trim().toLowerCase()); return expected.length === received.length && timingSafeEqual(expected, received); } ``` **Python** ```python # WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the # webhook's signature value. Check the bytes as received, before parsing, in constant time. def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool: if not header: return False expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header.strip().lower()) ``` ## Verify in code Read the raw body before any JSON parsing, compute the signature, and compare it with the header in constant time. These are the functions the [webhook receiver recipe](/recipes/webhook-receiver/) uses; in a shell, `openssl` computes the same value: _This sample is REST only._ **REST · cURL** ```bash # signature_of BODY: lowercase hex HMAC-SHA256 of the exact bytes, keyed with the webhook's # signature value. verify_signature BODY HEADER: succeeds when HEADER is that signature. signature_of() { printf '%s' "$1" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //' } verify_signature() { [ "$(signature_of "$1")" = "$(printf '%s' "$2" | tr -d '[:space:]' | tr 'A-F' 'a-f')" ] } ``` **REST · TypeScript** ```ts // WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the // webhook's signature value. Check the bytes as received, before parsing, in constant time. function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false; const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex")); const received = Buffer.from(header.trim().toLowerCase()); return expected.length === received.length && timingSafeEqual(expected, received); } ``` **REST · Python** ```python # WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the # webhook's signature value. Check the bytes as received, before parsing, in constant time. def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool: if not header: return False expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header.strip().lower()) ``` - In frameworks that parse JSON for you, ask for the raw body: in Express, `express.raw({ type: "application/json" })` instead of `express.json()`; in Flask, `request.get_data()`. - Answer a delivery that fails the check with 401. Never with 403 or 410: either disables the webhook at once, with no retry and no notice ([delivery and retries](/webhooks/delivery-and-retries/)). ## Replays and duplicates > **The signature does not stop replays:** There is no timestamp or nonce in the signed data, and no delivery ID. Anyone who captures a > delivery can send it again, and it still verifies. What the body does give you, inside the signed bytes: - **`event_time`**, when the change was saved, in UTC without an offset. A retry sends the same body for about 24 hours, so drop events older than 25 hours: anything older is a replay. - **`event`, `entity.id`, `entity.revision` and `event_time`** together identify an event. Keep the ones you processed for at least 25 hours and skip a repeat: a retry and a replay look the same. - **`team_space_id`**: when one receiver serves several spaces, check that it is the space the secret belongs to. If you trim the record with `load-only`, keep `id` and `revision` in it. The [webhook receiver recipe](/recipes/webhook-receiver/) does all of this, and its self-test sends a retry, an old event and a forgery to prove it. ## Keep the secret secret - Every read of a webhook returns its `signature`. Keep those responses out of logs, and give the **Webhooks** permission only to the applications that manage webhooks ([permissions and scopes](/get-started/permissions-and-scopes/)). - Use `https://` URLs and keep `insecure_ssl` at `false`. The API accepts `http://`, but then the body and its signature travel in clear text. - To rotate the secret, update the webhook with a new `signature`. A retry carries the signature it was first sent with, so accept the old secret too for about 24 hours. ## Limits - Version v1 is the only scheme. It has no timestamp, nonce or delivery ID. - Coevera does not sign deliveries of a webhook without a `signature`. - The checker on this page verifies v1 only. --- title: Delivery and retries description: When a delivery counts as delivered, how it is retried for about 24 hours, when a webhook is disabled without notice, and what to answer so it is not. canonical: https://developers.coevera.com/webhooks/delivery-and-retries/ section: webhooks publisher: Coevera --- # Delivery and retries **Answer:** A delivery succeeds on any 2xx within 10 seconds. Otherwise it is retried up to 10 times, 11 attempts over about 24 hours with exponential backoff. A 403 or 410 disables the webhook at once; running out of retries disables it too, silently. Deliveries are not ordered, and deleting a webhook sends DELETE to its URL. ## Success A delivery is one `POST` of a chunk of events to the webhook's URL. It counts as delivered when your endpoint answers any 2xx status within 10 seconds. Any other status, a timeout or a refused connection counts as a failure. Redirects are followed and the last response's status counts, so register the final URL. Answer first, then process: put the events in a queue and return 200, so slow work never runs into the timeout. The [webhook receiver recipe](/recipes/webhook-receiver/) does this. ## Retries A failed delivery is retried up to 10 times, 11 attempts in all, with exponential backoff. Each retry sends the same body, signed the same way; `Webhook-Delivery-Retry` says which attempt it is. | `Webhook-Delivery-Retry` | Sent after the previous attempt | Since the first attempt, about | | ------------------------ | ------------------------------- | ------------------------------ | | `0` | (first attempt) | 0 | | `1` | 30 s | 30 s | | `2` | 68 s | 1.6 min | | `3` | 2.6 min | 4.2 min | | `4` | 5.8 min | 10 min | | `5` | 13.3 min | 23 min | | `6` | 30.2 min | 54 min | | `7` | 69 min | 2 h | | `8` | 2.6 h | 4.6 h | | `9` | 5.9 h | 10.6 h | | `10` | 13.4 h | 24 h | The times are the waits the platform schedules; the request time and queueing add to them. ## When a webhook is disabled | Your endpoint | What happens | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Answers 403 or 410 | Disabled at once, with no retry. | | Answers 404 on the last attempt | Disabled. A 404 on an earlier attempt is retried. | | Fails every attempt | Disabled after the last one, unless the last failure was a timeout and the webhook delivered successfully, or was changed, in the 3 days before. | Disabling deletes the webhook (a soft delete): it drops out of the list of webhooks (a list with `include-deleted=true` still returns it, with `is_deleted: true`), its pending deliveries and retries are dropped, and no one is notified. So: - Never answer 403 or 410 by accident, for example on a signature that does not match: answer 401 ([security and signatures](/webhooks/security-and-signatures/)). - [List your webhooks](/reference/rest/webhooks/#list) on a schedule, and create the webhook again if it is gone. The events it missed meanwhile are not sent again: read the changes from the API, filtered on `modified` ([querying](/concepts/querying/#filter)) and page by page ([pagination](/concepts/pagination/)), as the [two-way contact sync](/recipes/two-way-contact-sync/) recipe does. ## Order and chunks - Deliveries are not ordered. Each chunk is retried on its own, so an older change can arrive after a newer one. Keep the highest `entity.revision` per record. - One change to many records is sent in chunks of up to `chunk_size` events (100 by default). With `chunk_delay`, chunk N, counted from 0, waits N × `chunk_delay` seconds ([filtering and options](/webhooks/filtering-and-options/)). - The record in a delivery is read when the delivery is built, after the change, not at the moment of the change. A retry hours later still carries the record as it was read then. ## When you delete a webhook Deleting a webhook through the API makes Coevera send `DELETE` to its URL, with a 10-second timeout. The request has no body and no signature header. The answer is not checked, but accept it with a 2xx so your logs stay clean. Your delete call waits for that request, so a slow receiver makes the delete slow. A webhook disabled after failures gets no such `DELETE`. ## Limits - There is no delivery log, no replay and no test event in Coevera, and no way to re-send what a disabled webhook missed. - No notification is sent when a webhook is disabled. - The retry schedule and the 10-second timeout are not configurable. --- title: Testing locally description: Test a webhook receiver on your machine with signed test deliveries, replays of real ones and the signature checker, and use a tunnel for real deliveries. canonical: https://developers.coevera.com/webhooks/testing-locally/ section: webhooks verification: behaviour lastVerified: 2026-10-15 publisher: Coevera --- # Testing locally **Answer:** Test without Coevera first: run the receiver locally and send it signed test deliveries, a retry and a forgery with the recipe's self-test or its cURL script. Replay a captured delivery with its original headers, and check signatures in the browser checker. For real deliveries, expose the receiver through an HTTPS tunnel and delete that webhook when you stop. Coevera has no test event and no delivery log, so you test a receiver with deliveries you make yourself. The programs below are the [webhook receiver recipe](/recipes/webhook-receiver/). ## Before you begin - The recipe's programs from `samples/recipes/webhook-receiver/rest/`: `index.ts` (Node.js 18 or later), `main.py` (Python 3.9 or later with `requests`) and `curl.sh` (`curl`, `jq` and `openssl`). - Your API credentials in `COEVERA_API_KEY` and `COEVERA_API_SECRET`: the test delivery is built from one of your opportunities ([authentication](/get-started/authentication/)). ## 1. Run the self-test Run the receiver without arguments. It starts on a free port, sends itself a signed delivery, the same delivery again as a retry, an event from two days ago, a forged one and a `DELETE`, and checks every answer: ```bash npx tsx index.ts ``` It prints `Answers: {"first":200,"retry":200,"old":200,"forged":401,"deleted":200}` and `Self-test passed.`: the retry was skipped as a duplicate and the old event dropped. `python main.py` does the same. ## 2. Send a delivery to your receiver Start the receiver with your webhook's secret, and keep it running: ```bash export WEBHOOK_SECRET="" npx tsx index.ts --serve ``` From a second terminal, send it a delivery signed the way Coevera signs. The cURL script builds one from one of your opportunities and posts it; set `RECEIVER_URL` if your receiver is not on `http://localhost:8787/`. The TypeScript and Python tabs show the self-test, which sends the same delivery and the rest: _This sample is REST only._ **REST · cURL** ```bash # One line of JSON, like a delivery; event_time is UTC without an offset. body=$(jq -cn --argjson entity "$record" --arg url "$RECEIVER_URL" --arg space "$SPACE_ID" \ --arg time "$(date -u +%Y-%m-%dT%H:%M:%S)" \ '[{webhook: {url: $url, events: ["Opportunity.Update"]}, event: "Opportunity.Update", event_time: $time, team_space_id: $space, entity: $entity, related_entity: null}]') signature=$(signature_of "$body") verify_signature "$body" "$signature" curl -sS --fail-with-body "$RECEIVER_URL" \ -H "Content-Type: application/json" \ -H "WebHook-Signature: $signature" \ -H "WebHook-Signature-Version: v1" \ -H "Webhook-Delivery-Retry: 0" \ --data-binary "$body" echo ``` **REST · TypeScript** ```ts // A delivery signed the way Coevera signs, then what retries, replays, forgeries and a // deleted webhook look like to the receiver. async function selfTest(url: string): Promise { const record = await findRecord(); const delivery = (eventTime: Date) => JSON.stringify([ { webhook: { id: randomUUID(), url, events: ["Opportunity.Update"] }, event: "Opportunity.Update", event_time: eventTime.toISOString().slice(0, -1), team_space_id: spaceId, entity: record, related_entity: null, }, ]); const send = (body: string, signature: string, retry = 0) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": String(retry) }, body, }).then((r) => r.status); const sign = (body: string) => createHmac("sha256", secret).update(body).digest("hex"); const fresh = delivery(new Date()); const old = delivery(new Date(Date.now() - 48 * 60 * 60 * 1000)); const answers = { first: await send(fresh, sign(fresh)), retry: await send(fresh, sign(fresh), 1), old: await send(old, sign(old)), forged: await send(fresh, "0".repeat(64)), deleted: await fetch(url, { method: "DELETE" }).then((r) => r.status), }; console.log("Answers:", JSON.stringify(answers)); const expected = { first: 200, retry: 200, old: 200, forged: 401, deleted: 200 }; if (JSON.stringify(answers) !== JSON.stringify(expected)) throw new Error(`Expected ${JSON.stringify(expected)}`); if (records.size !== 1 || seen.size !== 1) throw new Error("A retry or an old event was processed."); } ``` **REST · Python** ```python # A delivery signed the way Coevera signs, then what retries, replays, forgeries and a # deleted webhook look like to the receiver. def self_test(url): record = find_record() def delivery(event_time): return json.dumps([{ "webhook": {"id": str(uuid.uuid4()), "url": url, "events": ["Opportunity.Update"]}, "event": "Opportunity.Update", "event_time": event_time.replace(tzinfo=None).isoformat(), "team_space_id": SPACE_ID, "entity": record, "related_entity": None, }]).encode("utf-8") def sign(body): return hmac.new(SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() def send(body, signature, retry=0): headers = { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": str(retry), } status = requests.post(url, data=body, headers=headers, timeout=10).status_code jobs.join() # wait until the receiver has processed it, so the checks below see the result return status now = datetime.now(timezone.utc) fresh = delivery(now) old = delivery(now - timedelta(hours=48)) answers = { "first": send(fresh, sign(fresh)), "retry": send(fresh, sign(fresh), 1), "old": send(old, sign(old)), "forged": send(fresh, "0" * 64), "deleted": requests.delete(url, timeout=10).status_code, } print("Answers:", json.dumps(answers)) expected = {"first": 200, "retry": 200, "old": 200, "forged": 401, "deleted": 200} if answers != expected: sys.exit(f"Expected {json.dumps(expected)}") if len(records) != 1 or len(seen) != 1: sys.exit("A retry or an old event was processed.") ``` The receiver answers 200 with the number of events it received, and logs `stored revision` with the opportunity's revision. Send the same body again and it logs `duplicate, skipped`. ## 3. Replay a real delivery When your receiver logs a delivery it got wrong, keep its raw body, exactly as received, and its `WebHook-Signature`. Send both back to your local receiver with `curl --data-binary` and the same headers as above: the signature still verifies, so the receiver handles it exactly as before. That is also what an attacker with a captured delivery can do, which is why the receiver drops old events and skips repeats ([replays and duplicates](/webhooks/security-and-signatures/#replays-and-duplicates)). To check a captured delivery by hand, paste it into the [signature checker](/webhooks/security-and-signatures/#signature-checker). If it does not match, the checker says whether the body looks re-formatted. ## 4. Receive real deliveries Coevera calls only public addresses: a webhook URL on `localhost` or a private network is refused. To receive real deliveries on your machine, run an HTTPS tunnel that forwards a public URL to your receiver's port, and create a webhook for that URL with a `signature` of its own. - Use a test space, or narrow the webhook with `filter` so it receives only your test records ([filtering and options](/webhooks/filtering-and-options/)). - Delete the webhook when you stop. A webhook whose URL stops answering is retried for about 24 hours and then disabled; deleting it is cleaner, and Coevera sends your receiver a `DELETE`. - Never point a production webhook at a laptop. ## Limits - Coevera has no test event, no delivery log and no replay; the tools here stand in for them. - The self-test signs with a throwaway secret unless `WEBHOOK_SECRET` is set. - When the tunnel's public URL changes, update the webhook's `url`. --- title: API webhooks vs Automatizer webhooks description: Coevera has two separate ways to call a URL when something happens. They are set up, signed and retried differently; this is how to choose. canonical: https://developers.coevera.com/webhooks/api-vs-automatizer-webhooks/ section: webhooks verification: schema lastVerified: 2026-10-15 publisher: Coevera --- # API webhooks vs Automatizer webhooks **Answer:** Use API webhooks to mirror record changes into another system: you register them through the API, and every change arrives as a signed POST that is retried for about 24 hours. Use the Automatizer Send Webhook action when an admin needs one call as a step of a business process, with its own method, headers and body. The two share a name and nothing else: they are configured in different places, fire on different things and fail differently. Settings an admin sees in the Automatizer have no effect on API webhooks, and the other way round. The comparison below comes from the published API spec and the public GraphQL schema; the timeouts, retries and network rules come from the platform's code. ## Side by side | | API webhooks | Automatizer: Send Webhook | | ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Set up in | The API: the `Webhooks` entity, REST or GraphQL. No admin screen | The process editor, as a **Send Webhook** action | | Fires on | Any of 138 [record events](/webhooks/events/) | The process's own trigger: a record, a schedule, a manual start or an online form | | HTTP method | `POST` | `POST`, `GET`, `PUT`, `PATCH` or `DELETE` | | URL | `https://` or `http://`; private networks refused | `https://` only, and it can hold personalisation tags; private networks refused | | Body | Fixed: a JSON array of events | Your own text, JSON or XML, with personalisation tags | | Authentication | An optional HMAC-SHA256 signature in `WebHook-Signature`; no custom headers | Custom headers (a value marked encrypted is stored encrypted and masked when read back) and basic auth, its password masked | | Timeout | 10 s | 10 s; redirects are not followed | | Failures | Retried up to 10 times over about 24 hours, then the webhook is disabled | Handled per process run; see below | | Who maintains it | Developers, in code | Admins, in the CRM | ## Retries and repeated calls An API webhook retries a failed delivery with the same body, so your receiver must treat repeats as normal ([delivery and retries](/webhooks/delivery-and-retries/)). When the platform has to retry a scheduled Send Webhook step, it reuses the result of the call it already made instead of calling your URL again. That is best effort, so an endpoint that a process calls should still tolerate the occasional repeat. ## The Send Webhook URL The URL has no length limit, and percent-encoded URLs, such as Power Automate trigger URLs, are accepted. It must be `https://` and must not point to a private network. ## Which one to use **API webhooks** when you sync records to an ERP, a warehouse or your own app: you need every change, signed, with retries, and you write the receiver. Start with the [webhooks overview](/webhooks/overview/). **Automatizer Send Webhook** when a process should call a service as one of its steps: when a deal reaches a step, after an approval, on a schedule. An admin configures it in the CRM; see the help centre article [Automatizer: Using Webhook Actions](https://help.coevera.com/en/articles/3959195-automatizer-using-webhook-actions). ## Limits - API webhooks cannot send custom headers or a body of your own, or use a method other than `POST`. - The Automatizer cannot be triggered by an inbound webhook: its triggers are records, schedules, manual starts and online forms. To push data into Coevera, call the [API](/get-started/quickstart/), as the [lead from a web form](/recipes/lead-from-web-form/) recipe does. --- title: MCP server and AI connectors description: Let Claude, ChatGPT or another MCP client read and update a Coevera space through the Coevera MCP server, with the user's permissions and consent. canonical: https://developers.coevera.com/ai/overview/ section: ai publisher: Coevera --- # MCP server and AI connectors **Answer:** The Coevera MCP server at https://mcp-server.coevera.com/mcp gives AI assistants 20 tools over your CRM data. A space admin requests a connector in the Automation Hub and Coevera Customer Success activates it; each user then signs in with OAuth and approves the scopes. The connectors are free and in beta, and the server runs in the United States. **Status:** beta. **Price:** free. **Tools:** 20. **Sign-in:** OAuth 2.0 authorization code flow with PKCE. The MCP server implements the Model Context Protocol for Coevera. An assistant calls its tools; the server turns each call into requests to the Coevera REST and GraphQL APIs on behalf of the signed-in user, so the assistant works within that user's access and the scopes the user approved. ## Connectors A connector is how an assistant reaches the server. Each one is a tile in the Automation Hub, and each must be activated for the space before anyone can use it. | Connector | For | Setup | | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------- | | Claude Connector | Claude | [Connect Claude](/ai/connect-claude/) | | ChatGPT Connector | ChatGPT | [Connect ChatGPT](/ai/connect-chatgpt/) | | MCP Connector | Any other MCP client, with your own client ID, secret and redirect URLs | [Custom MCP connector](/ai/custom-connector/) | Activation is not self-serve: see [Activate a connector](/ai/activation/). ## Hosting and your data | Topic | Today | | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | Server | Hosted in the United States, whatever the region of your space. | | Your CRM data | Stays stored in its region. Requests and answers pass through the US server on their way to and from your space's API. | | Transport | Streamable HTTP, `POST /mcp`, a Bearer token on every request. | | Logs | Kept mostly in Ireland for up to 90 days. | | Status | Beta. The connectors are free. | Error reports go to a US-based error-monitoring service. The details are on [Hosting and your data](/ai/hosting-and-data/). ## Tools > **Tool reference:** The parameters and return values of every tool are in the > [MCP server's tool reference](https://mcp-server.coevera.com/doc#tools), published with the server and updated with each > version; each tool's name below opens its entry there. This page gives the overview. The server offers 20 tools in five groups. | Group | Tool | What it does | | ----------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Discovery | [`me`](https://mcp-server.coevera.com/doc#tool-me) | Who is signed in, in which space, with which scopes | | | [`meta_list_entity_types`](https://mcp-server.coevera.com/doc#tool-meta_list_entity_types) | The entities the user can access, with their create, read, update and delete permissions, and whether global search covers them | | | [`meta_describe_entity`](https://mcp-server.coevera.com/doc#tool-meta_describe_entity) | The fields of one entity, their types, and which can be filtered, expanded or searched | | Read | [`global_search`](https://mcp-server.coevera.com/doc#tool-global_search) | Find records by a text across accounts, contacts, leads, opportunities, tasks, appointments, projects, documents and custom entities, ranked by relevance | | | [`search_records`](https://mcp-server.coevera.com/doc#tool-search_records) | Filter, sort and page through the records of one entity | | | [`get_records`](https://mcp-server.coevera.com/doc#tool-get_records) | Fetch records by ID, with expanded relations | | | [`get_step_checklist_status`](https://mcp-server.coevera.com/doc#tool-get_step_checklist_status) | The checklist items of a pipeline step | | Report and export | [`run_report`](https://mcp-server.coevera.com/doc#tool-run_report) | Count, sum, average, minimum and maximum over the records that match a filter, grouped by up to three fields | | | [`export_records`](https://mcp-server.coevera.com/doc#tool-export_records) | Write the records that match a filter to a CSV file behind a one-time download link | | Write | [`create_records`](https://mcp-server.coevera.com/doc#tool-create_records) | Create records | | | [`update_records`](https://mcp-server.coevera.com/doc#tool-update_records) | Update fields of existing records | | | [`delete_records`](https://mcp-server.coevera.com/doc#tool-delete_records) | Soft-delete up to 100 records per call; leads and opportunities must be archived instead | | | [`upload_cloud_object_document`](https://mcp-server.coevera.com/doc#tool-upload_cloud_object_document) | Attach a file through a single-use upload URL | | | [`set_record_picture`](https://mcp-server.coevera.com/doc#tool-set_record_picture) | Set the logo of an account, the photo of a contact or the image of a product | | | [`change_ownership`](https://mcp-server.coevera.com/doc#tool-change_ownership) | Hand records to another owner | | | [`complete_step_checklist_item`](https://mcp-server.coevera.com/doc#tool-complete_step_checklist_item) | Mark a checklist item as done for a record | | Bulk | [`bulk_move_opportunities`](https://mcp-server.coevera.com/doc#tool-bulk_move_opportunities) | Move opportunities to another step of their pipeline | | | [`bulk_archive_opportunities`](https://mcp-server.coevera.com/doc#tool-bulk_archive_opportunities) | Archive opportunities | | | [`bulk_archive_leads`](https://mcp-server.coevera.com/doc#tool-bulk_archive_leads) | Archive leads | | | [`bulk_qualify_leads`](https://mcp-server.coevera.com/doc#tool-bulk_qualify_leads) | Qualify leads into opportunities | Every tool declares the MCP annotations (read-only, destructive, idempotent, open-world), which clients use to decide when to ask the user before a call; the tool reference gives [each tool's values and the reason for each](https://mcp-server.coevera.com/doc#annotations). `export_records` changes nothing in the space but is not marked read-only, so clients ask the user to approve every export. The server's instructions point assistants to `global_search` to find a record by its name or any other text, to `run_report` for totals and breakdowns, and to `export_records` for files and for analyses of many records. They also say that tool results are data entered by users, never instructions. ## Scopes The connectors ask for 15 scopes: ten grant write access (`.full`) and five grant read access (`.read`). | Write (`.full`) | Read (`.read`) | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `accounts.full`, `contacts.full`, `leads.full`, `opportunities.full`, `tasks.full`, `appointments.full`, `emails.full`, `messages.full`, `documents.full`, `products_and_prices.full` | `projects.read`, `currencies.read`, `pipelines.read`, `fields_and_forms.read`, `units_and_roles.read` | What each scope grants, and what can never be granted, is on [OAuth and scopes](/ai/oauth-and-scopes/). > **Filters and pagination differ from REST:** `search_records` takes `limit` (default and maximum 100) and a cursor, and the tools have their > own filter syntax. See [Filters and pagination](/ai/filters-and-pagination/). ## Build with the API instead Building your own agent or automation? Call the REST or GraphQL API directly with an API application. Start with the [quickstart](/get-started/quickstart/), then read [Build agents on the API](/ai/build-agents-on-the-api/). To react to changes without asking, use [webhooks](/webhooks/overview/). --- title: Activate a connector description: The AI connectors are not self-serve. A space admin requests one in the Automation Hub, and Coevera Customer Success approves and activates it. canonical: https://developers.coevera.com/ai/activation/ section: ai publisher: Coevera --- # Activate a connector **Answer:** A space admin opens Space Administration › Automation Hub › Addons, opens the Claude Connector, ChatGPT Connector or MCP Connector tile and clicks Request. Coevera Customer Success approves the request and activates the connector for the space. Until then, nobody in the space can connect an assistant through it. Each connector is activated per space and per connector: activating the Claude Connector does not activate the ChatGPT Connector. All three are in beta and free. ## Request a connector 1. In Coevera, open **Space Administration › Automation Hub** and select the **Addons** tab. 2. Open the tile of the connector you need. Each is marked **Beta**. - **Claude Connector** for Claude. - **ChatGPT Connector** for ChatGPT. - **MCP Connector** for any other MCP client, configured with your own redirect URLs (see [Custom MCP connector](/ai/custom-connector/)). 3. Click **Request**. Coevera Customer Success reviews the request and activates the connector for the space. Nothing is installed in the space. ## The connector is not active yet Until Customer Success approves the request, the connector cannot be used. A user who adds the server in an assistant can start the sign-in, but Coevera refuses the consent for a space whose connector is not active, so the assistant never gets a token. No turnaround time is published. To follow up on a request, contact support through the Contact Support form in the app, Support Chat, or the address in Coevera's [help-center article on contacting support](https://help.coevera.com/en/articles/2728995-how-to-contact-coevera-s-support-team), or contact Coevera through [coevera.com/contact](https://www.coevera.com/contact/). ## Who can connect once it is active Activation makes the connector available to the space. Each user then connects their own assistant, and the consent rules on [OAuth and scopes](/ai/oauth-and-scopes/) apply: only a user with the **Admin API Access** right in the space can approve the scopes. ## Disconnect To stop a connector for the space, disable it in the Automation Hub. A user can also remove the connector in the assistant. --- title: Connect Claude description: Four steps. An admin requests the Claude Connector and Coevera activates it; the user adds the server in Claude, signs in and approves the scopes. canonical: https://developers.coevera.com/ai/connect-claude/ section: ai publisher: Coevera --- # Connect Claude **Answer:** After Coevera activates the Claude Connector for your space, add a custom connector in Claude with the server URL https://mcp-server.coevera.com/mcp, then sign in to Coevera, pick the space and approve the scopes. Only a user with the Admin API Access right in the space can approve. Claude then works within that user's access and the approved scopes. The same server serves every client. For ChatGPT see [Connect ChatGPT](/ai/connect-chatgpt/); for any other MCP client see [Custom MCP connector](/ai/custom-connector/). ## Set up ### 1. An admin requests the connector in Coevera Go to **Space Administration › Automation Hub › Addons**, open **Claude Connector** and click **Request**. Coevera Customer Success approves the request and activates the connector for the space. See [Activate a connector](/ai/activation/). ### 2. Add the server in Claude In Claude, open **Settings › Connectors** and add a custom connector with this server URL: ```text https://mcp-server.coevera.com/mcp ``` ### 3. Sign in and approve Connecting opens the Coevera sign-in. The user signs in, picks a space and approves the scopes on the consent screen. - Only users with the **Admin API Access** right in the space can approve. - Only active spaces with an active subscription are offered. - File uploads, and exports that Claude downloads to analyse, need `*.coevera.com` in the allowed domains of Claude's network settings, because the file travels straight between Claude's environment and the server. ### 4. Try a first prompt Ask something your own records can answer, for example: > Which of my opportunities close this month, and what is the next step on each? To answer, Claude can call `me` to learn the user and space, then `search_records` on opportunities with a filter on the closing date. ## What users approve The consent screen lists the scopes the connector asks for, for example `accounts.full` or `pipelines.read`. Approving creates an application of type OAuth in the space, and Claude then works within the signed-in user's access and those scopes. The full list and what each scope grants are on [OAuth and scopes](/ai/oauth-and-scopes/). ## Troubleshooting > **The connector is not active yet:** The connectors are in beta. After an admin clicks **Request**, the connector stays inactive until > Coevera Customer Success approves it, and sign-in cannot complete until then. - **The consent is refused.** The user lacks the **Admin API Access** right in that space, or the Claude Connector is not active for it. - **The space is not offered at sign-in.** The space is not active or has no active subscription. - **Everything works except file uploads or exports.** Allow `*.coevera.com` in Claude. Upload and download links are single-use and expire after a few minutes, so start the upload or the export again. - **A request fails with a rate-limit error.** See [Limits and errors](/ai/limits-and-errors/). --- title: Connect ChatGPT description: Four steps. An admin requests the ChatGPT Connector and Coevera activates it; the user adds the server in ChatGPT, signs in and approves the scopes. canonical: https://developers.coevera.com/ai/connect-chatgpt/ section: ai publisher: Coevera --- # Connect ChatGPT **Answer:** After Coevera activates the ChatGPT Connector for your space, add a connector in ChatGPT with the server URL https://mcp-server.coevera.com/mcp, then sign in to Coevera, pick the space and approve the scopes. Only a user with the Admin API Access right in the space can approve. ChatGPT then works within that user's access and the approved scopes. The same server serves every client. For Claude see [Connect Claude](/ai/connect-claude/); for any other MCP client see [Custom MCP connector](/ai/custom-connector/). ## Set up ### 1. An admin requests the connector in Coevera Go to **Space Administration › Automation Hub › Addons**, open **ChatGPT Connector** and click **Request**. Coevera Customer Success approves the request and activates the connector for the space. See [Activate a connector](/ai/activation/). ### 2. Add the server in ChatGPT In ChatGPT's connector settings, add a connector with this server URL: ```text https://mcp-server.coevera.com/mcp ``` ChatGPT signs in with PKCE. The server has advertised the `S256` method since version 2.0.8. ### 3. Sign in and approve Connecting opens the Coevera sign-in. The user signs in, picks a space and approves the scopes on the consent screen. - Only users with the **Admin API Access** right in the space can approve. - Only active spaces with an active subscription are offered. - File uploads, and exports that ChatGPT downloads to analyse, need `*.coevera.com` in ChatGPT's allowed domains, because the file travels straight between ChatGPT's environment and the server. ### 4. Try a first prompt Ask something your own records can answer, for example: > Which of my opportunities close this month, and what is the next step on each? To answer, ChatGPT can call `me` to learn the user and space, then `search_records` on opportunities with a filter on the closing date. ## What users approve The consent screen lists the scopes the connector asks for, for example `accounts.full` or `pipelines.read`. Approving creates an application of type OAuth in the space, and ChatGPT then works within the signed-in user's access and those scopes. The full list and what each scope grants are on [OAuth and scopes](/ai/oauth-and-scopes/). ## Troubleshooting > **The connector is not active yet:** The connectors are in beta. After an admin clicks **Request**, the connector stays inactive until > Coevera Customer Success approves it, and sign-in cannot complete until then. - **The consent is refused.** The user lacks the **Admin API Access** right in that space, or the ChatGPT Connector is not active for it. - **The space is not offered at sign-in.** The space is not active or has no active subscription. - **Everything works except file uploads or exports.** Allow `*.coevera.com` in ChatGPT. Upload and download links are single-use and expire after a few minutes, so start the upload or the export again. - **A request fails with a rate-limit error.** See [Limits and errors](/ai/limits-and-errors/). --- title: Custom MCP connector description: Connect any other MCP client through the MCP Connector tile. Coevera issues the client ID and secret; you add your client's redirect URLs. canonical: https://developers.coevera.com/ai/custom-connector/ section: ai publisher: Coevera --- # Custom MCP connector **Answer:** For an MCP client other than Claude or ChatGPT, request the MCP Connector tile. Once Coevera activates it, its configuration dialog shows the server URL, a client ID and a client secret; you add your client's redirect URLs. Your client then signs users in with the OAuth 2.0 authorization code flow with PKCE (S256). Use the MCP Connector for developers' own clients, in-house assistants and approved third-party agents. It is meant for technical users; Claude and ChatGPT have their own connectors that need no configuration ([Connect Claude](/ai/connect-claude/), [Connect ChatGPT](/ai/connect-chatgpt/)). ## Before you begin - The **MCP Connector** is active for the space. An admin requests it under **Space Administration › Automation Hub › Addons** and Coevera Customer Success activates it; see [Activate a connector](/ai/activation/). - Your MCP client supports remote servers over Streamable HTTP and the OAuth authorization code flow with PKCE, with a pre-registered client. The server does not offer dynamic client registration. ## Configure the connector Open the **MCP Connector** tile in the Automation Hub. Its dialog, **OAUTH2 MCP Server - configure**, has three parts. **Credentials**, each with a copy button: | Field | Use it as | | ------------- | -------------------------------------------------------------------------------------------------------- | | Server URL | The MCP endpoint your client connects to | | Client ID | Your client's OAuth client ID | | Client Secret | Your client's OAuth client secret. It is masked; show it only to copy it into your client's secret store | The server URL is: ```text https://mcp-server.coevera.com/mcp ``` **Redirect URLs.** The list starts with the predefined redirect URLs of the supported AI clients. Add your client's callback URL, for example `https://agent.example.com/oauth/callback`. The list is an allow-list: an authorization request with any other redirect URL is refused. **Permissions Scopes.** The dialog lists an access level per scope: **Full Access**, **Read Only** or **No Access**. In release 6.5 a change made there is not saved. The server lists all 15 scopes on [OAuth and scopes](/ai/oauth-and-scopes/) as required, so have your client request all of them: a token that lacks a required scope is answered `403`. Save the dialog, then configure your client. ## Configure your client Give your client: - the server URL above; - the client ID and client secret, sent to the token endpoint as `client_secret_basic` or `client_secret_post`; - PKCE with the `S256` method. The client can read the endpoints from the server's discovery document: ```text https://mcp-server.coevera.com/.well-known/oauth-authorization-server ``` A request without a valid token is answered `401` with a `WWW-Authenticate` header that points to `https://mcp-server.coevera.com/.well-known/oauth-protected-resource`, so clients that follow RFC 9728 find the authorization server on their own. Clients that call the server directly must send `Accept: application/json, text/event-stream`, or the server answers `406`. ## Sign in and approve The first connection opens the Coevera sign-in. The user signs in, picks a space and approves the scopes. Only users with the **Admin API Access** right in the space can approve, and only active spaces with an active subscription are offered. ## Troubleshooting > **The connector is not active yet:** The connectors are in beta. After an admin clicks **Request**, the connector stays inactive until > Coevera Customer Success approves it, and sign-in cannot complete until then. - **The authorization request is refused.** Check that the redirect URL your client sends is in the dialog's list, exactly as written. - **`406 Not Acceptable`.** Send `Accept: application/json, text/event-stream`. - **Uploads or export downloads fail.** The file travels straight between your client's environment and the server's upload or download address: allow outbound requests to `*.coevera.com`. --- title: OAuth and scopes description: How the AI connectors sign in with OAuth 2.0 and PKCE, the 15 scopes the MCP server requires, what each grants, and what OAuth cannot grant. canonical: https://developers.coevera.com/ai/oauth-and-scopes/ section: ai publisher: Coevera --- # OAuth and scopes **Answer:** The AI connectors sign in with the OAuth 2.0 authorization code flow and PKCE (S256). The user approves 15 scopes: each .full scope grants write and each .read scope grants read on the matching permissions, and settings, Automation Hub and security permissions are never granted. OAuth applications for your own integrations are not yet available; use an API application. OAuth is how a user lets an assistant act in their Coevera space without giving it a password. On this site it is documented only as it applies to the MCP server and the AI connectors. ## How sign-in works 1. The client sends the user to Coevera's authorization endpoint with its client ID, a redirect URL from the connector's allow-list, the scopes and a PKCE challenge (`S256`). 2. The user signs in to Coevera and picks a space. Only active spaces with an active subscription are offered. 3. The consent screen lists the scopes. Coevera accepts the consent only if the user holds the **Admin API Access** right in that space and the connector is active for it. 4. The client exchanges the code for an access token at the token endpoint, authenticating with its client secret (`client_secret_basic` or `client_secret_post`) and the PKCE verifier. 5. The client calls `POST /mcp` with `Authorization: Bearer` and the token. The server checks the token on every request. The endpoints are published in the MCP server's discovery document: ```text https://mcp-server.coevera.com/.well-known/oauth-authorization-server ``` | Item | Value (discovery document, 2026-10-14) | | ---------------------- | ---------------------------------------------- | | Issuer | `https://mcp-server.coevera.com` | | Authorization endpoint | `https://crm.coevera.com/oauth2/app/authorize` | | Token endpoint | `https://crm.coevera.com/oauth2/app/token` | | Grant types | `authorization_code` | | PKCE methods | `S256` | | Client authentication | `client_secret_basic`, `client_secret_post` | Access tokens are valid for one hour. There is no dynamic client registration: the clients are registered by Coevera, behind the Claude Connector, the ChatGPT Connector and the MCP Connector. ## Scopes The server requires these 15 scopes. A token that lacks one is answered `403`. | Scope | Access | Covers | | -------------------------- | ------ | ------------------------------------- | | `accounts.full` | Write | Accounts | | `contacts.full` | Write | Contacts | | `leads.full` | Write | Leads | | `opportunities.full` | Write | Opportunities | | `tasks.full` | Write | Tasks | | `appointments.full` | Write | Appointments | | `emails.full` | Write | Emails | | `messages.full` | Write | Text messages | | `documents.full` | Write | Documents (files); needed for uploads | | `products_and_prices.full` | Write | Products and price lists | | `projects.read` | Read | Projects | | `currencies.read` | Read | Currencies | | `pipelines.read` | Read | Pipelines and their steps | | `fields_and_forms.read` | Read | Fields and forms | | `units_and_roles.read` | Read | Sales units and roles | A `.full` scope becomes **Write**, and a `.read` scope **Read**, on the matching entry of the application's permissions (the Records, Administration and Misc sections; see [permissions and scopes](/get-started/permissions-and-scopes/#oauth-scopes)). The **settings**, **Automation Hub** and **security** permissions can never be granted through OAuth, whatever the scopes. The scopes cap what the connector can do; they never extend it. The assistant still sees only what the signed-in user can see. ## The OAuth application in your space Approving the consent creates an application of type **OAuth** in the space, listed under **Administration › Units, Users & Roles › Applications**. Its name and permissions are read-only there, and it has no **Show Access**, because an OAuth application has no password to show. ## OAuth for your own integration OAuth applications for third-party integrations are not yet available: there is no way to register your own OAuth client. Integrations authenticate as an **API application**, with its user name and password over HTTP Basic: see [authentication](/get-started/authentication/) and [permissions and scopes](/get-started/permissions-and-scopes/), or start with the [quickstart](/get-started/quickstart/). --- title: Hosting and your data description: Where the MCP server runs, where your CRM data stays, which logs it keeps, and the path file uploads and exports take. For IT and security reviews. canonical: https://developers.coevera.com/ai/hosting-and-data/ section: ai publisher: Coevera --- # Hosting and your data **Answer:** The MCP server is hosted in the United States, whatever the region of your space. Your CRM data stays stored in its region, but requests and answers pass through the US server. Usage logs are kept mostly in Ireland for up to 90 days, with a 24-hour copy in the US, and error reports go to a US-based error-monitoring service. These are the answers an IT or security review usually asks for first. They describe the server at `https://mcp-server.coevera.com`, which every space uses. ## Where things run | Topic | Today | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP server | Hosted in the United States, for every space, whatever its region. There is no regional MCP server. | | Your CRM data | Stays stored in your space's region: us-east (Virginia), eu-central (Frankfurt), ca-central (Toronto) or ap-southeast (Sydney). | | Data in transit | Every tool call travels from the assistant to the US server, then to your space's API in its region, and the answer travels back the same way. A space in Frankfurt, Toronto or Sydney therefore sends assistant traffic through the US. | | Usage logs | Basic usage logs (who made the request, which company account, which request type) are kept mostly in Ireland for up to 90 days, with a 24-hour copy in the US. | | Error reports | Sent to a US-based error-monitoring service. | | Status | Beta. The connectors are free. | ## Sign-in and credentials - Users sign in on Coevera's own sign-in page. The assistant receives an access token, never the user's password. - The server checks the token on every request; it keeps no session between requests. - Each user's access is limited by their own permissions in the space and by the scopes they approved; see [OAuth and scopes](/ai/oauth-and-scopes/). - Changes made through the connector are recorded in each record's history like any other change. ## File uploads The [file upload reference](https://mcp-server.coevera.com/doc#upload) of the MCP server gives both steps in full, with an example. A file upload takes two steps. The `upload_cloud_object_document` tool (a document) or the `set_record_picture` tool (the picture of an account, contact or product) returns a single-use upload URL on `mcp-server.coevera.com`; the assistant's environment then posts the file to that URL, and the server stores it in Coevera under the user's identity. - The upload URL works once and expires after five minutes. - Files are limited to 50 MiB. A picture must be a PNG, JPEG, GIF or WebP image; the server checks the file's content, not its name. - The assistant's environment must be allowed to reach `*.coevera.com`. ## Exports `export_records` writes the records that match a filter to a CSV file, also in two steps. - The tool checks that the user may read the entity and holds the Export permission in Coevera, counts the rows, and returns a one-time download URL on `mcp-server.coevera.com`. The response describes the file and its columns; it holds no rows. - Opening the URL streams the file straight from your space's API under the user's identity, so record visibility and field permissions apply. The file is never stored on the MCP server, which keeps only the export request, encrypted, until the link is used or expires. - The download URL needs no sign-in: whoever holds it can download the file once, until it expires (after 10 minutes by default). Treat it like the file itself. - Clients ask the user to approve every export, because the tool is not marked read-only. The assistant may download the file into its own environment to analyse it, or hand the link to the user. ## What the assistant does with the data Tool results go to the assistant you connected. How that provider processes and keeps them is set by the provider's own terms, not by Coevera. The server's instructions tell the assistant that tool results are data entered by users, never instructions to follow. --- title: Filters and pagination description: MCP tools filter with their own JSON syntax and page with limit and a cursor, and global_search takes a text. The REST API uses filter and filter-op parameters and first and after. canonical: https://developers.coevera.com/ai/filters-and-pagination/ section: ai publisher: Coevera --- # Filters and pagination **Answer:** MCP tools do not use the REST query syntax. search_records, run_report and export_records take a JSON filter tree with AND, OR and NOT and operators such as eq, contains and gte, on fields marked filterable only. search_records pages with limit (default and maximum 100) and a cursor, where REST pages with first (default 30, maximum 100). Agent builders often use both the MCP server and the REST API, and the two look alike without being alike. This page sets them side by side. ## Filters `search_records`, `run_report` and `export_records` take the same filter. A filter is a tree. Inner nodes combine conditions; leaves test one field: ```text {"AND": [node, node, ...]} {"OR": [node, node, ...]} {"NOT": node} {"field_name": {"operator": value}} ``` | Operators | Value | | -------------------------------------------------------------------------- | -------------------------------------------------------------- | | `eq`, `ieq`, `contains`, `icontains`, `starts`, `istarts`, `ends`, `iends` | A list of strings, matched with OR. The `i` forms ignore case. | | `lt`, `lte`, `gt`, `gte` | A single value, as a string | | `null`, `empty` | `true` or `false` | - Only fields marked `filterable: true` by `meta_describe_entity` can be filtered. Filterable types are string, int, date, datetime, uuid and enum. - Relation fields cannot be filtered. A condition on one is refused with an error. - Call `meta_describe_entity` first: it gives the field names, their types and which can be filtered or expanded. ## Pagination `search_records` returns at most `limit` records, 100 by default and at most. When more exist, the response carries `has_next_page` and a `next_cursor`; pass the cursor as `after` or `cursor` in the next call to get the following page. Every tool rejects an argument name it does not know, with an error that lists the valid names, so a misspelt cursor fails instead of returning the first page again. ## Text search `global_search` takes a text instead of a filter and searches several entities at once, as the search bar in Coevera does. - The text is 2 to 200 characters. A record matches when every word of 3 or more characters appears in one of its searched fields, or when the whole text is a close match for one field. Words of 1 or 2 characters match the start of a word, and a phrase in double quotes must appear exactly. - Only the fields the space admin has set for global search are searched; `meta_describe_entity` lists them, and `meta_list_entity_types` says which entities global search covers. - Results are ranked by relevance, with counts per entity. Deleted records are left out; archived records are included and flagged. Each result links to its record; `get_records` gives the details. - It pages with `limit` (1 to 50, 20 by default) and `cursor`, the previous response's `next_cursor`. A cursor works only with the same text and entities. - Tasks and appointments are searched together. When you ask for only one of them, a page can hold fewer than `limit` results, so keep paging while there is a `next_cursor`. ## MCP and REST side by side | | MCP tools | REST API | | ---------------------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | Filter syntax | JSON tree in the tool arguments | `filter[field]` and `filter-op[field]` query parameters | | Negation | `NOT` node | `not-eq`, `not-ieq`, `not-null`, `not-empty` operators | | Range | `gt`, `gte`, `lt`, `lte` | The same, plus `between` | | Filter on a related record's field | Not supported | Dotted paths, for example `filter[owner.email]` | | Page size | `limit`, default 100, maximum 100 | `first`, default 30, maximum 100 | | Next page | `after` or `cursor`, with the previous `next_cursor` | `after` with the previous `page_info.end_cursor` | | More pages? | `has_next_page` | `page_info.has_next_page` | | Related records | `expand` | `expand`, dot notation | The REST side is described in [querying](/concepts/querying/) and [pagination](/concepts/pagination/). The MCP side is specified in the tool reference's [filter syntax](https://mcp-server.coevera.com/doc#filter) and [expand](https://mcp-server.coevera.com/doc#expand) sections. ## Expand `get_records` returns related records with `expand`. Name the relation and the related fields, either in flat form (`"relation": ["field", "nested.field"]`) or in dotted form (`"relation.nested": ["field"]`). An empty relation comes back as `[]`. --- title: Limits and errors description: The MCP server's HTTP statuses, tool error codes, rate limits and per-call limits, and where they differ from the REST API. canonical: https://developers.coevera.com/ai/limits-and-errors/ section: ai publisher: Coevera --- # Limits and errors **Answer:** The MCP server answers 401 for a missing or expired token, 403 when the token lacks a scope, 422 for invalid input and 429 with Retry-After at a rate limit. It limits bursts per token and sustained use per space; the thresholds are not published. A 429 is plain HTTP, so an agent may report it as a transport failure. ## HTTP statuses The MCP server's [troubleshooting](https://mcp-server.coevera.com/doc#troubleshooting) section lists the same statuses. | Status | Meaning | What to do | | ------ | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `401` | The token is missing, expired or malformed. | Sign in again. | | `403` | The token lacks a required scope. | Sign in again and approve all scopes. | | `406` | The request has no supported `Accept` header. | Send `Accept: application/json, text/event-stream`. | | `422` | Invalid input: bad filter syntax, an unknown field, a missing parameter. | Fix the arguments; `meta_describe_entity` lists the valid fields. | | `429` | A rate limit was hit. | Wait for the `Retry-After` seconds, then retry. | | `500` | An unexpected server error. | Retry later. | | `503` | Coevera could not be reached, or answered unexpectedly. The server has already retried 5 times, 30 seconds apart. | Retry later. | > **A 429 is not a tool error:** The `429` is sent as a plain HTTP response before the MCP layer, not as a tool result. Some > agents and clients report it as a transport or connection failure. If an agent stops with a > connection error during a burst of calls, check for a rate limit before anything else. ## Tool errors A tool that runs but cannot do its job returns an error result with one of these codes: `invalid_input`, `permission_denied`, `api_error`, `server_error`, `read_only_field_violation`, `missing_id`, `invalid_id`. Write tools report per record. `create_records` returns one result per input, in input order. `update_records` skips the invalid records and still submits the valid ones, so read every result, not only the first. - Writes are validated as in Coevera itself. A record that breaks a rule (a required field, a field value, a relation, a step checklist or a custom validation rule) comes back as an error for that record and is not stored, so the assistant can correct it. - `picture_id` on accounts, contacts and products cannot be written with `create_records` or `update_records`: they answer `read_only_field_violation` for that record and name `set_record_picture`. - A tool rejects an argument name it does not know, with an error that lists the valid names. ## Rate limits The server limits requests in two stages: a short burst limit per token and a longer sustained limit per space. Either answers `429` with `Retry-After`. The thresholds are not published. The API calls a space can make each month depend on its plan; see [coevera.com/crm/pricing](https://www.coevera.com/crm/pricing/). ## Per-call limits | Tool or endpoint | Limit | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `search_records` | `limit` up to 100 records per call | | `global_search` | A text of 2 to 200 characters; `limit` from 1 to 50, 20 by default | | `run_report` | Up to 10 metrics and 3 group-by fields; up to 500 groups (`limit_groups`, 50 by default). It reads at most `scan_cap` records (10,000 by default) within a time budget; `truncated` and `truncated_reason` say when the result covers only part of the matching records. | | `export_records` | Up to 100 fields, and at most `max_rows` rows per file (10,000 by default). The download link works once and expires after `expires_in_seconds` (10 minutes by default). | | `delete_records` | Up to 100 IDs per call. Leads and opportunities cannot be deleted; archive them. | | `bulk_move_opportunities`, `bulk_archive_opportunities`, `bulk_archive_leads` | Batches of 20 or more run asynchronously. | | `bulk_qualify_leads` | Runs synchronously at any batch size. | | File upload (`upload_cloud_object_document`, `set_record_picture`) | 50 MiB per file, given in `max_file_bytes`. The upload URL works once and expires after five minutes. A picture must be a PNG, JPEG, GIF or WebP image. | Long calls send progress notifications while they run, when the client accepts a stream. `run_report` and `export_records` share a few scan slots on each server worker. When all of them are busy, a call waits for a while and then gets a server-busy error; retry it later. ## Upload and download links The single-use links that the upload tools and `export_records` return answer with their own statuses. The two-step flow is in the [file upload reference](https://mcp-server.coevera.com/doc#upload). | Status | Upload link (`/upload/{token}`) | Download link (`/download/{token}`) | | ------ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `200` | The file is stored; the body describes the document, or the record and its new picture. | The CSV file. | | `400` | The body could not be read. The link is used up. | Not returned | | `403` | Not returned | The user's role no longer allows exporting. | | `410` | The link expired or was already used. Call the tool again. | The link expired or was already used. Call the tool again. | | `413` | The file is larger than `max_file_bytes`. The link is used up. | Not returned | | `415` | `set_record_picture` only: the file is not a PNG, JPEG, GIF or WebP image. The link is used up. | Not returned | | `500` | Coevera refused the upload, for example a wrong folder or no write permission on documents. | Not returned | | `502` | Not returned | Coevera refused the request. | | `503` | All upload slots are busy: retry the same link after `Retry-After`. | All download slots are busy: retry the same link after `Retry-After`. | If Coevera fails partway through a download, the connection is cut off, so the download fails instead of ending as a short file. ## Compared with the REST API | Situation | MCP server | REST API | | ------------------ | --------------------------------------- | -------------------------------------------------------------------------------- | | Missing permission | `403` | `400` with code `50`, `ERROR_NOT_ENOUGH_PERMISSIONS` | | Rate limit | `429` with `Retry-After` | `429`, `ERROR_TOO_MANY_REQUESTS` (code `182`), with `Retry-After` when available | | Invalid input | `422` | `400` or `422`, depending on the error | | Error body | MCP error result with a tool error code | JSON with `success`, `http_status`, `code`, `name` and `message` | The REST API's side is in [errors](/concepts/errors/), the [error codes](/reference/errors/) and [rate limits and retries](/concepts/rate-limits-and-retries/). --- title: MCP changelog description: Changes to the Coevera MCP server that clients and agents can see, by server version, newest first. Separate from the API changelog. canonical: https://developers.coevera.com/ai/mcp-changelog/ section: ai publisher: Coevera --- # MCP changelog **Answer:** The MCP server has its own versions, released independently of Coevera's monthly releases. The current version is 2.1.0, from October 2026. This page lists the changes clients and agents can see, newest first, from the server's own release notes; changes to the REST and GraphQL API are in the API changelog. The server's own [release notes](https://mcp-server.coevera.com/doc#release-notes), published with its [tool reference](https://mcp-server.coevera.com/doc#tools), give a month for each version, not a day. This page follows them and leaves out changes internal to the server. Changes to the REST API, GraphQL and webhooks are in the [API changelog](/changelog/api/). ## 2.1.0: October 2026 - **Added:** `global_search`, which finds records by a text across accounts, contacts, leads, opportunities, tasks, appointments, projects, documents and custom entities with global search turned on, in one call and ranked by relevance, as the search bar in Coevera does. It returns counts per entity and a short result per record, with a link to it. - **Added:** `run_report`, which counts, sums and averages, and finds minimums and maximums, over the records that match a filter, grouped by up to three fields, with dates by month, quarter or year. Amounts are totalled in the space's base currency, and the response says when and why a result covers only part of the records. - **Added:** `export_records`, which writes the records that match a filter to a CSV file behind a one-time download link on the new `/download/{token}` address. The response describes the file and its columns, not the rows. Exporting needs the Export permission in Coevera, and clients ask the user to approve every export. - **Changed:** `meta_list_entity_types` and `meta_describe_entity` say whether `global_search` covers an entity, and which of its fields it searches. - **Changed:** the server's instructions point assistants to `global_search`, `run_report` and `export_records`, and say that tool results are data entered by users, never instructions. - **Changed:** every tool description fits the 2,048 characters that claude.ai shows. - **Changed:** `search_records` takes the pagination cursor as `cursor` as well as `after`. - **Fixed:** every tool rejects an argument name it does not know, with an error that lists the valid names. A cursor sent under a wrong name used to be ignored, and `search_records` returned the first page again. - **Fixed:** `meta_describe_entity` no longer lists fields the REST API cannot load, which made searches that asked for them fail. - **Fixed:** record links no longer contain a double slash. ## 2.0.11: October 2026 - **Fixed:** `set_record_picture` failed on every call before it returned the upload link. ## 2.0.10: October 2026 - **Added:** `set_record_picture`, which sets the logo of an account, the photo of a contact or the image of a product through a single-use upload link, as file uploads do. The file must be a PNG, JPEG, GIF or WebP image. - **Changed:** `create_records`, `update_records` and `change_ownership` no longer skip Coevera's validations. A write that breaks a rule (a required field, a field value, a relation, a step checklist or a custom validation rule) comes back as an error for that record instead of being stored, so some writes that used to succeed now fail. - **Changed:** `picture_id` on accounts, contacts and products cannot be written with `create_records` or `update_records`, which answer `read_only_field_violation` for that record and name `set_record_picture`. `create_records` now reports read-only fields per record, as `update_records` does. - **Fixed:** a document uploaded with `upload_cloud_object_document` can no longer be set as a record's picture. ## 2.0.9: October 2026 **Changed:** minor improvements and fixes. The server's notes list no details. ## 2.0.8: September 2026 - **Added:** `delete_records`, a soft delete of up to 100 records per call, with a result per record. - **Changed:** delete is allowed on more entities and relation types. Leads and opportunities still cannot be deleted; archive them instead. - **Added:** documents (`CloudObject`) and document folders (`CloudObjectFolder`) can be created and updated. - **Changed:** uploads are limited to 50 MiB, and the limit is returned to the client as `max_file_bytes`. - **Changed:** the OAuth issuer is the server's own origin, `https://mcp-server.coevera.com`, and the discovery document advertises PKCE with `S256`. - **Added:** a `401` carries a `WWW-Authenticate` header that points to the protected-resource metadata. ## 2.0.7: September 2026 - **Added:** `upload_cloud_object_document`, a two-step upload: the tool returns a single-use upload URL, and the client posts the file to it. ## 2.0.1 to 2.0.6: July 2026 - **Changed:** corrected tool annotations (2.0.2, 2.0.3), with a justification for each hint (2.0.4). - **Changed:** fixed a mismatch in the OAuth scopes (2.0.1). ## 2.0.0: June 2026 - **Added:** `change_ownership`, for tasks, opportunities, contacts, accounts, appointments, leads and memos. - **Added:** the bulk tools `bulk_move_opportunities`, `bulk_archive_opportunities`, `bulk_archive_leads` and `bulk_qualify_leads`, with a rollback option. Batches of 20 or more run asynchronously. - **Added:** the step checklist tools `get_step_checklist_status` and `complete_step_checklist_item`. - **Changed:** custom lookup fields appear among an entity's expandable fields. ## 1.1.5: June 2026 - **Changed:** every tool annotation hint is set explicitly to true or false. ## 1.1.4: May 2026 - **Added:** support for ChatGPT's connector verification. - **Changed:** write tools report errors per record. - **Changed:** a failure of the rate-limit store answers a structured `503`. ## 1.1.3: May 2026 - **Added:** progress notifications over SSE for long calls. - **Added:** the `units_and_roles.read` scope. - **Changed:** `get_records` no longer caps the number of records returned. - **Changed:** tool output schemas adjusted for ChatGPT, and tool titles and descriptions revised. --- title: Build agents on the API description: When to build your own agent or automation on the REST or GraphQL API instead of the MCP server, and how it authenticates. canonical: https://developers.coevera.com/ai/build-agents-on-the-api/ section: ai publisher: Coevera --- # Build agents on the API **Answer:** To build your own agent or automation, call the Coevera REST or GraphQL API with an API application's user name and password over HTTP Basic. The MCP server suits assistants that a person signs in to and supervises; it offers 20 fixed tools. OAuth applications for third-party integrations are not yet available, so API applications are the documented path. ## MCP server or API? | | MCP server | REST and GraphQL API | | ------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Who it acts as | The person who signed in and approved the scopes | An API application, with the permissions an admin gave it | | Sign-in | OAuth, through a connector that Coevera activates | The application's user name and password over HTTP Basic | | Coverage | 20 tools over the main record types, custom entities included | Every entity in the space, custom entities included, plus fields and pipelines | | Reacting to changes | Not available: the assistant has to ask | [Webhooks](/webhooks/overview/) for record events | | Business actions | Bulk move, archive and qualify; change of owner | Qualify, move and change of owner in GraphQL ([REST or GraphQL?](/get-started/rest-or-graphql/#business-actions)) | | Where it runs | United States, for every space | Your space's own region | | Limits | Burst per token and sustained per space; thresholds not published | A monthly call quota by plan ([pricing](https://www.coevera.com/crm/pricing/)) | Use the MCP server when a person works through an assistant such as Claude or ChatGPT. Use the API when your own code runs without a person in the loop: a sync, a scheduled job, a back-office agent, or a service that reacts to webhooks. ## Authenticate as an API application An API application is a user type for integrations. It has its own user name and password and its own permissions, so each integration gets only the access it needs, and its writes show its name in the record history. The [quickstart](/get-started/quickstart/#get-api-credentials) shows how to create one and make a first call, and [authentication](/get-started/authentication/) how its credentials are sent and rotated. OAuth applications for third-party integrations are not yet available. OAuth exists only behind the AI connectors; see [OAuth and scopes](/ai/oauth-and-scopes/). ## Tips for agent builders - **Give the application only the permissions the agent needs.** Permissions are set per application, in the Records, Administration and Misc sections ([permissions and scopes](/get-started/permissions-and-scopes/)). - **Read the schema of the actual space.** Spaces differ: custom fields, custom entities and renamed entities. An authenticated request for `/api/v100/rest/spaces/{space_id}/openapi.json` returns an OpenAPI document built for that space, its custom fields and custom entities included ([tools](/get-started/tools/#your-spaces-own-documents)). - **Branch on the error `code`.** A missing permission answers `400` with code `50`, not `403`; [errors](/concepts/errors/) lists what each status means. - **Retry on `429`.** Wait for the seconds in `Retry-After`, then retry ([rate limits and retries](/concepts/rate-limits-and-retries/)). - **Check every item of a batch.** A batch write can succeed for some records and fail for others; read the result for each item ([batch operations](/concepts/batch-operations/#partial-failures)). - **Give the agent these docs.** Every page here is also plain Markdown, listed in `llms.txt`; see [Use these docs with AI](/ai/use-the-docs-with-ai/). --- title: Use these docs with AI description: Every page of this site is also served as plain Markdown, and llms.txt lists them all, so an assistant or a coding agent can read the docs directly. canonical: https://developers.coevera.com/ai/use-the-docs-with-ai/ section: ai publisher: Coevera --- # Use these docs with AI **Answer:** Every page on this site has a plain Markdown version at its address with .md in place of the trailing slash, for example /ai/overview.md. The file /llms.txt lists every page with a one-line summary and a link to its Markdown version. Give either address to an assistant. There is no MCP server for the documentation itself. These docs are written to be read by people and by AI assistants alike. Nothing here needs a plugin or an account. ## Markdown versions Replace the trailing slash of any page address with `.md` to get the page as Markdown: | Page | Markdown version | | -------------------------- | -------------------------------------------------------- | | `/ai/overview/` | [/ai/overview.md](/ai/overview.md) | | `/get-started/quickstart/` | [/get-started/quickstart.md](/get-started/quickstart.md) | The Markdown version carries the page's title, description, canonical address and verification label in its front matter, then the short answer and the full text. Callouts become quotes with a bold label, so no site-specific syntax is left in it. ## llms.txt [/llms.txt](/llms.txt) is the index of the site in the [llms.txt](https://llmstxt.org) format: one line per page, grouped by section, with a one-line summary, a link to the page's Markdown version and its verification label. ## Giving the docs to an assistant - An assistant that can fetch web pages can read a Markdown address or `llms.txt` directly. Ask it to read the page before it answers. - Otherwise, open the Markdown version and paste its text into the conversation. - A coding agent can fetch `llms.txt`, pick the pages it needs, and read their Markdown versions. ## Verification labels A verified page states how it was checked in its footer, in the front matter of its Markdown version and in `llms.txt`: | Label | Meaning | | ----------- | ------------------------------------------------------------------------ | | `behaviour` | Its examples were run against a live Coevera space and behaved as shown. | | `schema` | It matches the API specification. | Ask an assistant to say which label a page carries when it relies on it. --- title: Integration platforms description: Choose between Zapier, the Microsoft Power Platform connector and the Coevera API, by what each covers, how it signs in and where it stops. canonical: https://developers.coevera.com/integrations/overview/ section: integrations publisher: Coevera --- # Integration platforms **Answer:** Use Zapier for event-driven no-code flows on the main record types. Use the Power Platform connector inside Microsoft tools when getting, creating, updating or deleting five record types is enough; it has no triggers. For anything else, including webhooks, business actions and other entities, build on the API. Coevera can be connected without writing code, or with very little. When a platform stops short, the API takes over. ## Platforms ### Zapier The **Coevera** app on Zapier has triggers, actions and searches for accounts, appointments, contacts, custom entities, leads, opportunities, products and tasks. - Triggers for new and updated records. - Create and update actions, plus creating product line items. - Searches and search-or-create steps, including **Find Owner**. - Signs in with an API application. Read the guide: [Zapier](/integrations/zapier/). ### Microsoft Power Platform A connector for Power Automate, Power Apps and Copilot Studio (Premium) and Logic Apps (Standard), listed by Microsoft under the company's former name. - Get, create, update and delete, by record ID. - Accounts, contacts, leads, opportunities and tasks. - No triggers. - 100 calls per connection per minute, a limit set by Microsoft. Read the guide: [Microsoft Power Platform](/integrations/power-platform/). ### Build on the API The REST and GraphQL APIs reach every entity and field in a space, custom entities included. [Webhooks](/webhooks/overview/) report record changes as they happen, and GraphQL carries [business actions](/get-started/rest-or-graphql/#business-actions) such as qualifying a lead or moving an opportunity. Start with the [quickstart](/get-started/quickstart/), and see the [recipes](/recipes/overview/) for complete programs. Platforms without a Coevera listing, such as Make, Tray or n8n, connect through the [REST API](/reference/rest/): call it from their HTTP modules with an API application's credentials ([authentication](/get-started/authentication/)). ## When to use which | | Zapier | Power Platform | API | | -------------------------------- | -------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | | Who builds it | Operations and admins | Microsoft makers | Developers | | React to changes | Yes, triggers | No triggers | Yes, webhooks | | Record types | 8, plus product line items | 5 | Every entity, custom entities included | | Business actions (qualify, move) | No | No | Yes, in GraphQL | | Delete records | No | Yes | Yes | | Limits | Your Zapier plan | 100 calls per minute per connection (Microsoft) | Monthly API calls by plan ([pricing](https://www.coevera.com/crm/pricing/)) | ## Before you connect a platform Zapier and the Power Platform connector both sign in with an **API application**: its user name and password, the space ID and the service URL, all shown under **Show Access** for the application. Create one application per platform and give it only the [permissions](/get-started/permissions-and-scopes/) its flows need. The [quickstart](/get-started/quickstart/#get-api-credentials) shows where to create it, and [authentication](/get-started/authentication/) how to rotate its credentials. --- title: Zapier description: The Coevera app on Zapier, with its 16 triggers, 17 actions, 9 searches and 7 search-or-create steps, how it signs in, and where it stops. canonical: https://developers.coevera.com/integrations/zapier/ section: integrations publisher: Coevera --- # Zapier **Answer:** The Coevera app on Zapier has 16 triggers for new and updated accounts, appointments, contacts, custom entities, leads, opportunities, products and tasks, 17 create and update actions, 9 searches and 7 search-or-create steps. It signs in with an API application's user name and password, the service URL and the space ID. This page describes the public listing at [zapier.com/apps/coevera/integrations](https://zapier.com/apps/coevera/integrations). Zapier publishes the app as **Coevera**. ## Connect Coevera to Zapier 1. In Coevera, create an API application for Zapier and give it the [permissions](/get-started/permissions-and-scopes/) your Zaps need. The [quickstart](/get-started/quickstart/#get-api-credentials) shows where to create it. 2. Select the application and open **Show Access**. Keep the dialog open: it shows the four values Zapier asks for. 3. In Zapier, connect the Coevera app and enter the application's **user name** and **password**, the **space ID** and the **service URL**. Use a separate API application for Zapier, so you can change its permissions or its password ([authentication](/get-started/authentication/#rotate-the-credentials)) without touching your other integrations. ## Triggers Sixteen triggers, a **New** and an **Updated** trigger for each of these record types: | Record type | Triggers | | ------------- | ---------------------------------------- | | Account | New Account, Updated Account | | Appointment | New Appointment, Updated Appointment | | Contact | New Contact, Updated Contact | | Custom Entity | New Custom Entity, Updated Custom Entity | | Lead | New Lead, Updated Lead | | Opportunity | New Opportunity, Updated Opportunity | | Product | New Product, Updated Product | | Task | New Task, Updated Task | ## Actions Seventeen actions: - **Create** (9): Account, Appointment, Contact, Custom Entity, Lead, Opportunity, Product, Product Line Item, Task. - **Update** (8): Account, Appointment, Contact, Custom Entity, Lead, Opportunity, Product, Task. ## Searches - **Find** (9): Account, Appointment, Contact, Custom Entity, Lead, Opportunity, Owner, Product, Task. **Find Owner** looks up a Coevera user, for example to set the owner of a new record. - **Search or create** (7): Account, Appointment, Contact, Lead, Opportunity, Product, Task. The step finds a record, or creates it when none matches. ## Where it stops The app has no delete actions, no business actions such as qualifying a lead or moving an opportunity to another step, and no steps for quotes, projects or documents. For those, call the API, or use Zapier's own HTTP steps with an API application's credentials. See [Integration platforms](/integrations/overview/) for how the options compare. --- title: Microsoft Power Platform description: The Coevera connector for Power Automate, Power Apps, Copilot Studio and Logic Apps covers five record types by ID and has no triggers. canonical: https://developers.coevera.com/integrations/power-platform/ section: integrations publisher: Coevera --- # Microsoft Power Platform **Answer:** Microsoft lists the Coevera connector as Pipeliner CRM (Preview). It gets, creates, updates and deletes accounts, contacts, leads, opportunities and tasks by ID, and has no triggers. It signs in with an API application's user name and password, and every action also asks for the service URL and space ID. Microsoft limits it to 100 calls per connection per minute. This page describes the connector as Microsoft documents it in its [connector reference](https://learn.microsoft.com/en-us/connectors/pipelinercrm/). In the Microsoft products, search for **Pipeliner CRM (Preview)**: the connector carries the company's former name. ## Where it is available | Product | Class | | -------------- | -------- | | Power Automate | Premium | | Power Apps | Premium | | Copilot Studio | Premium | | Logic Apps | Standard | Microsoft does not offer it in its US government, China or US Department of Defense clouds. ## Connect 1. In Coevera, create an API application for the connector and give it the [permissions](/get-started/permissions-and-scopes/) your flows need. The [quickstart](/get-started/quickstart/#get-api-credentials) shows where to create it. 2. In Power Automate, Power Apps, Copilot Studio or Logic Apps, add the connector and create a connection with the application's **user name** and **password**. 3. In each action, enter the **Service URL** (it starts with `https://`) and the **Space ID**. Both are shown under **Show Access** for the application. The connection cannot be shared. When you share a Power App that uses it, each user is asked to create their own connection. ## Actions Twenty actions, four for each of five record types: | Record type | Get | Create | Update | Delete | | ----------- | --- | ------ | ------ | ------ | | Account | Yes | Yes | Yes | Yes | | Contact | Yes | Yes | Yes | Yes | | Lead | Yes | Yes | Yes | Yes | | Opportunity | Yes | Yes | Yes | Yes | | Task | Yes | Yes | Yes | Yes | **Get** returns one record by its ID, and **Delete** removes one record by its ID. **Update** modifies one or more records by ID. There is no action that lists or searches records. ## No triggers The connector cannot start a flow when something changes in Coevera. For event-driven flows, either: - register an [API webhook](/webhooks/overview/) that posts to a flow that starts on an HTTP request, or - use [Zapier](/integrations/zapier/), whose Coevera app has triggers for new and updated records. ## Limits - **100 calls per connection per 60 seconds.** This is Microsoft's throttling of the connector, not a Coevera limit. - Coevera's own limit is the number of API calls a space can make each month, which depends on its plan; see [coevera.com/crm/pricing](https://www.coevera.com/crm/pricing/). ## Where it stops Only five record types: no appointments, products, quotes, projects, documents or custom entities. No business actions such as qualifying a lead or moving an opportunity, and no list or search. For those, call the API, from a flow's HTTP action with an API application's credentials or from your own code. See [Integration platforms](/integrations/overview/) for how the options compare. --- title: API changelog description: Every change that affects integrations through the REST API, GraphQL and webhooks since release 6.0, newest first, with the breaking changes first and what to do about them. canonical: https://developers.coevera.com/changelog/api/ section: changelog verification: behaviour lastVerified: 2026-10-16 publisher: Coevera --- # API changelog **Answer:** This changelog lists each change that affects integrations through the REST API, GraphQL and webhooks since release 6.0, grouped by release, newest first. Each release lists its breaking changes first, each with the action an integration needs, then what was deprecated, removed, changed, added or changed for security, and the fixes. The API version stays v100. Changes that affect integrations built on the REST API, GraphQL and webhooks, since release 6.0. Each release lists its breaking changes first, with what to do about them; product features are in Coevera's [release notes](https://www.coevera.com/crm/release-notes/), and MCP server versions in the [MCP changelog](/ai/mcp-changelog/). > **Breaking changes:** A **Breaking** change may need a change in existing code, and its **Action** says which. There is > no fixed deprecation period, and a removal without one says so. Policy: > [Versioning and deprecation](/changelog/versioning-and-deprecation/). ## Change types | Type | Meaning | | -------------- | ------------------------------------------------------------- | | **Deprecated** | Still works, and will be removed. | | **Removed** | No longer available. | | **Changed** | Existing behaviour that now works differently. | | **Added** | Something new that integrations can use. | | **Security** | A change made for security, and what an integration now gets. | | **Fixed** | A request that failed now works, or fails with a clear error. | Subscribe to new entries with the [RSS feed](/changelog/feed.xml). ## Release 6.6 6.6.0 (2026-10-29) ### Breaking changes #### App-only mutations removed from the public schema **Removed** · GraphQL `markEmailsAsReadEmail`, `markEmailsAsUnreadEmail`, `updateEmailThreadContentShareModeEmail`, `bulkDeleteEmailFromMailboxEmail`, `createImageCloudObjectTemplate`, `purchasePhone` and `syncFromCpPhone` serve only the Coevera apps and were never documented for integrations. They are removed without a deprecation period, and a call fails with `Cannot query field`. **Action:** Remove these calls from your integration. #### `getByIdsFor…` queries removed from `cloudObjectTemplate` **Removed** · GraphQL `getByIdsForProcess`, `…ForEmailSequence`, `…ForReportSchedule`, `…ForApprovalProcess` and `…ForFieldMapping` are gone from the `cloudObjectTemplate` repository, without a deprecation period. The queries of the same names on `cloudObject` are unchanged. **Action:** Remove calls to these queries on `cloudObjectTemplate`. #### Three repository queries return a different shape **Changed** · GraphQL - `entityScoring.getByEntityType` returns a list, in configured order, instead of one record. - `entitySettings.getForEntity` returns one record or `null` instead of a list. - `leadType.getByLeadProcess` requires `leadProcessId`. **Action:** Update how you read these results, and pass `leadProcessId`. #### At most 200 mutations and 500 aliases per operation **Changed** · GraphQL A larger operation is refused with HTTP `400` before any of it runs. Aliases inside a fragment count every time the fragment is spread. **Action:** Split large operations, or write many records with one `bulkUpdate…` mutation. #### `merge` validates `master` like an update **Changed** · REST `POST /entities/Accounts/merge` and `/entities/Contacts/merge` refuse read-only or unknown fields in `master` with code `117`, and nothing is merged. **Action:** Send only fields that `PATCH` accepts. #### Saving a process or sequence checks template and document access **Changed** · REST, GraphQL The caller needs read access to the e-mail templates and documents that the save adds. Otherwise the save fails with code `50`. **Action:** Give the API application at least **Read Only Access** to **Emails** and **Documents**. #### OpenAPI tags renamed, and related records' `id` is a UUID **Changed** · REST The tags become `API Access` (was `Api`), `Field` (`Data`, `Type`), `Online Form` (`Online`) and `Text Message` (`Text`, `Unsubscribed`), related records' `id` is a UUID instead of a URI, and request bodies declare `application/json` correctly. Every operation and field now has a description, and the API itself behaves as before. **Action:** If you generate a client from the spec, expect renamed classes when you regenerate. ### Changed #### Opportunity `value` follows its line items when auto-calculated **Changed** · REST, GraphQL When `is_value_auto_calculate` is `true`, creating, updating or deleting a line item recalculates the opportunity's `value`, as the app does. The flag defaults to `false` for opportunities created through the API. #### `created` and `modified` in the future are ignored **Changed** · REST, GraphQL A value more than one day in the future is dropped on write; past dates, used to import history, are still stored. Set `validation-level` bit `8` to store the value anyway. #### A mass e-mail cannot be unlinked from its recipient **Changed** · REST, GraphQL Deleting the `MessageRelations` row that links a mass e-mail to the contact or account it was sent to fails with code `2002`. Relations that e-mail sync adds later can still be deleted. #### Picture fields accept only images **Changed** · REST, GraphQL A picture field, such as `Account.picture_id`, refuses a cloud object that is not an image with code `117`, at every `validation-level`. Before, skipping validation let it through. #### The Pipelines permission covers quote processes **Changed** · REST, GraphQL An API key or OAuth token with the Pipelines permission can now access quote processes (`QuoteProcess`). ### Added #### E-mail visibility settings on roles **Added** · REST, GraphQL `MasterRights` have `access_entity_email_access_level`, which limits which e-mails users of the role see on records (at level `1`, to the users and units in `access_entity_email_allowed`), and three `access_entity_email_link_to_*` settings for linking e-mails to accounts and contacts. #### Step checklist e-mails can go to a lookup field's record **Added** · REST, GraphQL `send_email_lookup_field_id` takes the recipient from a Contact or Account lookup field. Empty means the primary contact or account, as before. #### Webhooks can have a name **Added** · Webhooks A webhook's `options` take `name`, a label that does not change delivery. A value that is not a string is refused with code `117`. ### Security #### Files from a storage URL must belong to your space **Security** · REST, GraphQL A `CloudObject` created with a `url` on Coevera's file storage is kept only when the file belongs to the caller's space; any other storage path is refused with code `117`. `getSignedUrls` leaves out paths with dot segments. ### Fixed - REST, GraphQL: Input the API cannot store (`NaN`, `Infinity`, NUL characters, unpaired surrogates, over-long indexed text, deep nesting) returns code `117` instead of a server error. - GraphQL: `convert…` mutations apply their `validationLevel`. - GraphQL: `restoreLead`, `restoreOpportunity` and `restoreQuote` return the record when it was not deleted, instead of `null`. - GraphQL: `reactivateLeadOppty` and `bulkReactivateOpportunity` keep the closing date when none is sent, and `bulkReactivateQuote` keeps the expiration date. - REST: The OpenAPI document no longer lists `validation-level` on `DELETE`, `batch-delete` and `batch-restore`, which never accepted it. - REST: `PATCH /entities/FieldMappings/{id}`, `batch-delete` of folders and project objectives, and `batch-modify` of targets work instead of failing with a server error. - REST: `batch-modify` finds IDs written in upper case, and answers code `143` for a record the caller cannot see instead of a server error. - REST: A query string that is not UTF-8 and a malformed `AppointmentSchedules` body answer HTTP `400` instead of a server error. - REST: `WebResources` with an unknown entity answer code `117`, and a `Phones` update with an unknown ID code `143`, instead of a server error. - REST: `TextMessages` and `TextMessageConversations` answer `DELETE` and `batch-delete` with code `171` and `batch-modify` with `116`, instead of a server error. - REST: `EntityFitnessIndicators` without `settings` answer `ERROR_PARAMETER_MISSING`, and `ProfileRelations` and `ClientFavorites` need exactly one target, instead of a server error. ## Release 6.5 6.5.3 (2026-10-15) · 6.5.2 (2026-10-08) · 6.5.1 (2026-10-02) · 6.5.0 (2026-10-01) ### Breaking changes #### `EmailMailboxRelations` are read-only **Removed** · REST, GraphQL · 6.5.3 Create, update and delete are gone from both APIs without a deprecation period, because Coevera writes these rows itself when it sends or syncs e-mail. REST answers code `116`, GraphQL no longer has the mutations, and reads are unchanged. **Action:** Stop writing `EmailMailboxRelations`. #### `validation-level` refuses internal bits, and `-1` **Security** · REST · 6.5.3 A REST create, update or `batch-modify` whose `validation-level` sets either of two internal bits is refused with code `117`, and nothing is written. `-1`, which sets every bit, is refused too. **Action:** Send the bits or member names in [Validation](/concepts/validation/) instead of `-1`. #### Steps cannot be deleted through REST **Removed** · REST · 6.5.2 `DELETE /entities/Steps/{id}` and `batch-delete` answer code `171`, without a deprecation period. Deleting a step used to leave its records on a deleted step. **Action:** Remove the step from its pipeline or process instead, which moves its records to another step. #### `fitness` is nullable and only computed in `getById` **Changed** · GraphQL · 6.5.2 `LeadEntity.fitness`, `OpportunityEntity.fitness` and their `fitnessDescription` are nullable, and `null` outside `getById`. Selecting them in a list used to fail. **Action:** Handle `null`, or read single records with `getById`. #### Custom fields cannot be created on `LeadOppty` or `Activity` **Changed** · REST, GraphQL · 6.5.0 `POST /entities/Fields`, `createField` and `createDraftField` refuse an abstract entity, such as `LeadOppty` or `Activity`, with code `117`. Such a field used to be created but could never be published. **Action:** Create the field on `Lead`, `Opportunity`, `Task` or `Appointment` and share it with `mapDependency`. ### Changed #### Scoring and target filters must start from their own entity **Changed** · REST, GraphQL · 6.5.2 An `EntityScorings` filter must start from the scored entity, and a `Targets` filter from `Lead` or `LeadOppty`, depending on the target type. Otherwise the write is refused with code `117`. #### Every e-mail has a `thread_id` **Changed** · REST, GraphQL · 6.5.0 `Email.thread_id` is never `null`: an e-mail created without one gets its own `id`. A standalone e-mail can now be moved into another thread; changing any other `thread_id` is still refused with code `2002`. #### Single-currency plans cannot add currencies **Changed** · REST, GraphQL · 6.5.0 In a space whose subscription supports only its base currency, creating or restoring a currency is refused with code `50`. When a space moves to such a plan, its other currencies are deleted and amounts stay in the base currency without conversion. ### Added #### E-mail labels **Added** · REST, GraphQL · 6.5.0 `EmailLabels` and `EmailLabelRelations` hold a user's personal labels and the conversations they are on, and `EmailMailboxRelations` have `label_ids`. An API application needs its e-mail permission for them. #### E-mails record the account that sent them **Added** · REST, GraphQL · 6.5.0 `Email.sender_email_account_id` is the personal e-mail account that sent the e-mail, or `null`; GraphQL can also filter on it. #### Validate and compare settings payloads against their JSON Schemas **Added** · REST · 6.5.0 `POST /api/v100/rest/schemas/{RootType}/validate` returns the violations of a settings payload, and `…/diff` compares two payloads. Neither needs authentication, and each accepts 60 requests per minute per IP address. #### Products list their price lists and schedules **Added** · REST, GraphQL · 6.5.0 `Product.price_lists` and `Product.price_schedules` are read-only lists of `{id, name}`: the active price lists and schedules that price the product. ### Security #### `plain_password` is no longer returned to integrations **Security** · REST, GraphQL · 6.5.2 For a migrated API access, `plain_password` reads `null` for requests made with an API key or an OAuth token. The value returned when the access is created is unchanged. #### Files created from a URL are fetched only from public addresses **Security** · REST, GraphQL · 6.5.1 When a `CloudObject` is created with a `url`, Coevera fetches only `http` and `https` URLs whose host and redirects resolve to public addresses. Other URLs are refused with code `117`. #### OAuth authorization errors redirect only to registered URIs **Security** · REST · 6.5.0 An error for an unsupported `response_type` is redirected only to a `redirect_uri` registered for the client. For any other URI it is shown without a redirect. ### Fixed - 6.5.2 · REST, GraphQL: Unknown paging cursors and sorts through a relation to the same entity (`parent_account.name`) return code `117`, and a `picture` without `url` code `119`, instead of a server error. - 6.5.2 · REST, GraphQL: Merging a record into itself is refused with code `117`; it used to delete the only copy of the record. - 6.5.0 · REST, GraphQL: Malformed `settings`, tag, filter, `orderBy`, `duration` and `meeting_url` input returns code `117` instead of a server error. - 6.5.0 · REST: `PATCH` with `validation-level` on draft-and-publish entities (`Fields`, the record types, `Translations` …) no longer fails with a server error. - 6.5.0 · REST, GraphQL: A task or appointment with `project_relations: null` is saved with no project relations. - 6.5.0 · REST, GraphQL: An appointment created by an integration with a Microsoft Teams or Zoom `meeting_url` and no join URL gets its meeting created for its owner. ## Release 6.4 6.4.7 (2026-09-24) · 6.4.6 (2026-09-17) · 6.4.5 (2026-09-09) · 6.4.4 (2026-09-03) · 6.4.2 (2026-09-01) · 6.4.0 (2026-08-27) ### Breaking changes #### AI-applied dates can no longer be sorted on in GraphQL **Removed** · GraphQL · 6.4.4 `lastAiAccountHierarchyApplied` and `lastAiOrgChartApplied` (accounts) and `lastAiBuyingCenterApplied` (leads and opportunities) are gone from the order-by inputs, without a deprecation period. They can still be read and filtered on, in REST and GraphQL. **Action:** Remove these fields from `orderBy`. ### Changed #### Record type forms keep their required and forbidden fields **Changed** · REST · 6.4.6 A write of `form_edit` or `form_lost` on a record type entity, such as `TaskTypes`, is refused with code `117` when it places a field that cannot be on the form, or leaves out one that must be, such as a task's due date and status. #### `batch-modify` merges `validation-level` lists with OR **Changed** · REST · 6.4.5 A comma-separated `validation-level` on `batch-modify` is merged with OR, as on every other route: `6,2` means `6`. Before, `batch-modify` added the values, and read `6,2` as `8`. #### Deleting an e-mail deletes its unsent draft replies **Changed** · REST, GraphQL · 6.4.4 `DELETE /entities/Emails/{id}`, `batch-delete` and `deleteEmail` also delete the drafts that reply to the e-mail, in every mailbox. Before, the drafts stayed and pointed at a deleted e-mail. #### Filters on multi-select fields match an option **Changed** · REST, Webhooks · 6.4.0 `filter[]=