---
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="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
```

**REST · cURL**

```bash
COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
```

**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 @- <<JSON
{
  "name": "Contoso fleet telemetry rollout",
  "owner_id": "$OWNER_ID",
  "step_id": "$STEP_ID",
  "closing_date": "2027-03-31",
  "value": { "base_value": 42000, "value_foreign": 42000, "currency_id": "$CURRENCY_ID" },
  "account_relations": [{ "account_id": "$ACCOUNT_ID", "is_primary": true }],
  "contact_relations": [{ "contact_id": "$CONTACT_ID", "is_primary": true }]
}
JSON
)
echo "$opportunity"
OPPORTUNITY_ID=$(jq -er '.data.id' <<<"$opportunity")
```

**REST · TypeScript**

```ts
const created = await coevera("/entities/Opportunities", {
  method: "POST",
  body: {
    name: "Contoso fleet telemetry rollout",
    owner_id: ownerId,
    step_id: stepId,
    closing_date: "2027-03-31",
    // In the base currency, value_foreign equals base_value.
    value: { base_value: 42000, value_foreign: 42000, currency_id: currencyId },
    account_relations: [{ account_id: accountId, is_primary: true }],
    contact_relations: [{ contact_id: contactId, is_primary: true }],
  },
});
const opportunityId: string = created.data.id;
console.log("Opportunity:", opportunityId);
```

**REST · Python**

```python
created = coevera(
    "POST",
    "/entities/Opportunities",
    json={
        "name": "Contoso fleet telemetry rollout",
        "owner_id": owner_id,
        "step_id": step_id,
        "closing_date": "2027-03-31",
        # In the base currency, value_foreign equals base_value.
        "value": {"base_value": 42000, "value_foreign": 42000, "currency_id": currency_id},
        "account_relations": [{"account_id": account_id, "is_primary": True}],
        "contact_relations": [{"contact_id": contact_id, "is_primary": True}],
    },
)
opportunity_id = created["data"]["id"]
print("Opportunity:", opportunity_id)
```

**GraphQL · cURL**

```bash
# currencyId is required; the API computes valueForeign from baseValue.
opportunity=$(jq -n --arg owner "$OWNER_ID" --arg step "$STEP_ID" --arg currency "$CURRENCY_ID" \
  --arg account "$ACCOUNT_ID" --arg contact "$CONTACT_ID" '{
  query: "mutation CreateOpportunity($input: CreateOpportunityInput!) { createOpportunity(input: $input) { opportunity { id name } } }",
  variables: {
    input: {
      name: "Contoso fleet telemetry rollout",
      ownerId: $owner,
      stepId: $step,
      closingDate: "2027-03-31",
      value: { baseValue: 42000, currencyId: $currency },
      accountRelations: [{ accountId: $account, isPrimary: true }],
      contactRelations: [{ contactId: $contact, isPrimary: true }]
    }
  }
}' | gql)
echo "$opportunity"
OPPORTUNITY_ID=$(jq -er '.data.createOpportunity.opportunity.id' <<<"$opportunity")
```

**GraphQL · TypeScript**

```ts
const created = await gql(
  `mutation CreateOpportunity($input: CreateOpportunityInput!) {
    createOpportunity(input: $input) {
      opportunity { id name }
    }
  }`,
  {
    input: {
      name: "Contoso fleet telemetry rollout",
      ownerId: owner.id,
      stepId: step.id,
      closingDate: "2027-03-31",
      // currencyId is required; the API computes valueForeign from baseValue.
      value: { baseValue: 42000, currencyId: currency.id },
      accountRelations: [{ accountId: account.id, isPrimary: true }],
      contactRelations: [{ contactId: contact.id, isPrimary: true }],
    },
  },
);
const opportunityId: string = created.createOpportunity.opportunity.id;
console.log("Opportunity:", opportunityId);
```

**GraphQL · Python**

```python
created = gql(
    """
    mutation CreateOpportunity($input: CreateOpportunityInput!) {
      createOpportunity(input: $input) {
        opportunity { id name }
      }
    }
    """,
    {
        "input": {
            "name": "Contoso fleet telemetry rollout",
            "ownerId": owner["id"],
            "stepId": step["id"],
            "closingDate": "2027-03-31",
            # currencyId is required; the API computes valueForeign from baseValue.
            "value": {"baseValue": 42000, "currencyId": currency["id"]},
            "accountRelations": [{"accountId": account["id"], "isPrimary": True}],
            "contactRelations": [{"contactId": contact["id"], "isPrimary": True}],
        }
    },
)
opportunity_id = created["createOpportunity"]["opportunity"]["id"]
print("Opportunity:", opportunity_id)
```

**Recorded response · REST · 201 Created**

```json
{
  "success": true,
  "data": {
    "is_delete_protected": false,
    "id": "0b000000-0000-4000-a000-000000000001",
    "is_deleted": false,
    "modified": "2026-10-15 09:00:00.055014+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": 590,
    "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": ""
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "createOpportunity": {
      "opportunity": {
        "id": "0b000000-0000-4000-a000-000000000001",
        "name": "Contoso fleet telemetry rollout"
      }
    }
  }
}
```

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

## 7. Update a custom field

In REST, a custom field is written like any other field, at the top level of the body, under its
`api_name`. In GraphQL, `customFields` takes one JSON string keyed by `api_name`, and returns the
values keyed in camelCase: `cf_competitor` comes back as `cfCompetitor`.

**REST · cURL**

```bash
# A custom field is written like any other field, under its api_name.
curl -sS --fail-with-body -X PATCH "$COEVERA_BASE/entities/Opportunities/$OPPORTUNITY_ID" \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "cf_competitor": "In-house BI team" }' >/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.
