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.
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_), a single-line text field on Opportunity. Use a field of your own space: GET / lists them with their api_; see Fields and custom fields. |
| Context | Region and space ID come from the context bar. Credentials come from $COEVERA_ and $COEVERA_. |
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:
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>" # never commit itCOEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");
// 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 bodyCOEVERA_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"]){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0d000000-0000-4000-a000-000000000001",
"name": "Dana Whitfield"
}
]
}{
"data": {
"entities": {
"client": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "0d000000-0000-4000-a000-000000000001",
"name": "Dana Whitfield"
}
}
]
}
}
}
}
}2. 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"]){
"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"
}
]
}{
"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"]){
"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"
}
]
}{
"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"]){
"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
}
]
}{
"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"]){
"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"
}
]
}{
"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){
"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
{
"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"]){
"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
{
"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:
codeandname:40000ERROR_ENTITY_VALIDATIONfor a record that fails validation;entity_errors: problems with the record as a whole, each with acodeand amessage;field_errors: one entry per field, withfield_nameand theerrorson it, here119ERROR_PARAMETER_MISSINGonname.
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()}"){
"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
{
"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 }
}' | gqlconst 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)){
"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
{
"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(GraphQLedges) means the e-mail, the pipeline name or the base currency was not found. - Step 6 answers 201 (GraphQL: 200), and
data.id(GraphQLcreateOpportunity.opportunity.id) is the opportunity the next steps use. - Step 7 answers 200, and the record carries
cf_competitor(GraphQLcfCompetitor). - Step 8 answers 422 (GraphQL: 200 with
errorsand"updateOpportunity": null), with a field error onname. - Step 9 returns the name unchanged by step 8,
cf_competitor, the Validate step, Contoso Freight and Lena Okafor, andvalue_foreignequal tobase_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
nameand 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-modifycall withrollback-method=1and check every item.