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

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

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

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

More in the quickstart

RecipesCreate an opportunity

Create an 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

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

The operations this recipe calls, in the API reference: REST GET /entities/Clients, GET /entities/Accounts, GET /entities/Contacts, GET /entities/Steps, GET /entities/Currencies, POST /entities/Opportunities, PATCH /entities/Opportunities/{id}, GET /entities/Opportunities; GraphQL ClientRepository.getByCriteria, AccountRepository.getByCriteria, ContactRepository.getByCriteria, StepRepository.getByCriteria, CurrencyRepository.getByCriteria, createOpportunity, updateOpportunity, OpportunityRepository.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:

Shell
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
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;
}
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
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"
}
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;
}
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.

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")
// 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);
owners = coevera(
    "GET",
    "/entities/Clients",
    params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"},
)
if not owners["data"]:
    sys.exit("No user with that e-mail in this space.")
owner_id = owners["data"][0]["id"]
print("Owner:", owners["data"][0]["name"], owner_id)
owner=$(jq -n '{
  query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }"
}' | gql)
echo "$owner"
OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner")
const owners = await gql(`{
  entities {
    client {
      getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
        edges { node { id name } }
      }
    }
  }
}`);
const 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);
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"])
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "success": true,
  "total": 1,
  "page_info": {
    "start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "has_previous_page": false,
    "has_next_page": false
  },
  "data": [
    {
      "id": "0d000000-0000-4000-a000-000000000001",
      "name": "Dana Whitfield"
    }
  ]
}
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "client": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0d000000-0000-4000-a000-000000000001",
                "name": "Dana Whitfield"
              }
            }
          ]
        }
      }
    }
  }
}

2. Find the account

Look the account up by its e-mail address, email1.

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

3. Find the contact

Look the contact up the same way.

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")
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);
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)
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")
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);
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"])
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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"
    }
  ]
}
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "contact": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0c000000-0000-4000-a000-000000000001",
                "firstName": "Lena",
                "lastName": "Okafor"
              }
            }
          ]
        }
      }
    }
  }
}

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.

# 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")
// 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);
# 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)
# 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")
// 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);
# 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"])
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
    }
  ]
}
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "step": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "07000000-0000-4000-a000-000000000001",
                "name": "Validate",
                "percent": 30
              }
            }
          ]
        }
      }
    }
  }
}

5. Find the base currency

The opportunity's value needs a currency. The space's base currency is the one with is_base: true.

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")
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);
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)
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")
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);
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"])
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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"
    }
  ]
}
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "currency": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "04000000-0000-4000-a000-000000000001",
                "code": "USD"
              }
            }
          ]
        }
      }
    }
  }
}

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

# 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")
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);
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)
# 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")
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);
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)
201 CreatedRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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": ""
  }
}
Show all 81 linesShow less
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "createOpportunity": {
      "opportunity": {
        "id": "0b000000-0000-4000-a000-000000000001",
        "name": "Contoso fleet telemetry rollout"
      }
    }
  }
}

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.

# 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."
// 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.");
# 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.")
# 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
// 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);
# 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"])
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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"
  }
}
Show all 81 linesShow less
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "updateOpportunity": {
      "opportunity": {
        "id": "0b000000-0000-4000-a000-000000000001",
        "customFields": "{\"cfCompetitor\": \"In-house BI team\"}"
      }
    }
  }
}

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 lists the envelope, the statuses and the codes you meet most.

# 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"
// 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()}`);
  }
}
# 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()}")
# 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"
// 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()}`);
  }
}
# 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()}")
422 Unprocessable EntityRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
}
Show all 29 linesShow less
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
  }
}
Show all 46 linesShow less

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

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"
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));
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))
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
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));
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))
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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"
    }
  ]
}
Show all 33 linesShow less
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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\"}"
        }
      }
    }
  }
}
Show all 29 linesShow less

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.