RecipesProducts and line items
Products and line items
Create a product category and a product, set its list price, and add it to an opportunity as a line item with a price and a quantity, in REST or GraphQL.
Answer
Create the category, then the product with name, unit_symbol and a unique sku. Set its price in the opportunity's price list, then add an OpptyProductRelations line with oppty_id, product_id, price and quantity. Never send a discount: discount_value and discount_percentage are computed against the list price, and REST refuses them with 400.
Northwind Analytics starts selling a Data quality audit. Create it in a new Analytics services category, give it a list price of 4500, and add three audits at a partner rate of 4000 to Northwind's open opportunity. You end with a line item whose amount and discount the API computed.
Before you start
| Permissions | Full Access to Products & Prices on the Administration tab, Full Access to Opportunities and Read Only Access to Accounts on the Records tab. |
|---|---|
validation-level |
The default (0, full validation). |
| Batch behaviour | No batches: every write is one record. |
| Context | Region and space ID come from the context bar. Credentials come from $COEVERA_ and $COEVERA_. |
A line item (OpptyProductRelations, in the app a product on the opportunity) holds the
product, the quantity and the per-unit price. The API computes the rest: amount, and
discount_value and discount_percentage against the product's price in the opportunity's
price list (product_price_list_id).
The programs need curl and jq, Node.js 18 or later, or Python 3.9 or later with requests.
Authentication 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/Accounts,
GET /entities/Opportunities,
POST /entities/ProductCategories,
POST /entities/Products,
GET /entities/ProductPriceListPrices,
PATCH /entities/ProductPriceListPrices/{id},
POST /entities/OpptyProductRelations,
GET /entities/OpptyProductRelations; GraphQL
AccountRepository.getByCriteria,
OpportunityRepository.getByCriteria,
createProductCategory,
createProduct,
ProductPriceListPriceRepository.getByCriteria,
updateProductPriceListPrice,
createOpptyProductRelation,
OpptyProductRelationRepository.getByCriteria.
Set up a helper
Every step below uses this setup. Export the credentials first:
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>" # never commit itCOEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");
async function coevera(path: string, init: { method?: string; body?: unknown } = {}) {
const res = await fetch(`${base}${path}`, {
method: init.method ?? "GET",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${String(body.message).trim()}`);
return body;
}BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])
def coevera(method, path, **kwargs):
response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs)
body = response.json()
if not response.ok:
sys.exit(f"HTTP {response.status_code} {body.get('name')}: {str(body.get('message')).strip()}")
return bodyCOEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public"
# gql: POST the GraphQL request read from stdin. GraphQL reports errors in the body, often
# with HTTP 200, so fail on those too.
gql() {
local response
response=$(curl -sS --fail-with-body "$COEVERA_GRAPHQL" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @-)
if jq -e '.errors' <<<"$response" >/dev/null; then
echo "$response" >&2
return 1
fi
echo "$response"
}const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");
// gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200.
async function gql(query: string, variables: object = {}) {
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const body = await res.json();
if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`);
return body.data;
}ENDPOINT = (
"https://{region}.api.coevera.com/api/v100/app/space/"
"{space_id}/graphql/public"
)
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])
def gql(query, variables=None):
"""One GraphQL request. GraphQL reports errors in the body, often with HTTP 200."""
response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30)
body = response.json()
if not response.ok or body.get("errors"):
sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}")
return body["data"]1. Find the account
Look the account up by its e-mail address.
account=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \
--data-urlencode "filter[email1]=northwind@example.com" \
-d first=1 -d load-only=id,name \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$account"
ACCOUNT_ID=$(jq -er '.data[0].id' <<<"$account")const accounts = await coevera(
`/entities/Accounts?${new URLSearchParams({ "filter[email1]": "northwind@example.com", first: "1", "load-only": "id,name" })}`,
);
const accountId: string | undefined = accounts.data[0]?.id;
if (!accountId) throw new Error("No account with that e-mail.");
console.log("Account:", accounts.data[0].name, accountId);accounts = coevera(
"GET",
"/entities/Accounts",
params={"filter[email1]": "northwind@example.com", "first": 1, "load-only": "id,name"},
)
if not accounts["data"]:
sys.exit("No account with that e-mail.")
account_id = accounts["data"][0]["id"]
print("Account:", accounts["data"][0]["name"], account_id)account=$(jq -n '{
query: "{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: [\"northwind@example.com\"] } }) { edges { node { id name } } } } } }"
}' | gql)
echo "$account"
ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$account")const accounts = await gql(`{
entities {
account {
getByCriteria(first: 1, filter: { email1: { eq: ["northwind@example.com"] } }) {
edges { node { id name } }
}
}
}
}`);
const account = accounts.entities.account.getByCriteria.edges[0]?.node;
if (!account) throw new Error("No account with that e-mail.");
console.log("Account:", account.name, account.id);accounts = gql("""
{
entities {
account {
getByCriteria(first: 1, filter: { email1: { eq: ["northwind@example.com"] } }) {
edges { node { id name } }
}
}
}
}
""")
edges = accounts["entities"]["account"]["getByCriteria"]["edges"]
if not edges:
sys.exit("No account with that e-mail.")
account = edges[0]["node"]
print("Account:", account["name"], account["id"]){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0a000000-0000-4000-a000-000000000001",
"name": "Northwind Analytics"
}
]
}{
"data": {
"entities": {
"account": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "0a000000-0000-4000-a000-000000000001",
"name": "Northwind Analytics"
}
}
]
}
}
}
}
}2. Find the opportunity and its price list
Find the account's opportunity through its account relations. product_price_list_id names the
price list its line items take their prices from; the next steps price the product there.
# The account's opportunity, with the price list its line items take their prices from.
opportunity=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \
-d "filter[account_relations.account_id]=$ACCOUNT_ID" \
-d first=1 -d load-only=id,name,product_price_list_id \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$opportunity"
OPPORTUNITY_ID=$(jq -er '.data[0].id' <<<"$opportunity")
PRICE_LIST_ID=$(jq -er '.data[0].product_price_list_id' <<<"$opportunity")// The account's opportunity, with the price list its line items take their prices from.
const opportunities = await coevera(
`/entities/Opportunities?filter[account_relations.account_id]=${accountId}&first=1&load-only=id,name,product_price_list_id`,
);
const opportunity = opportunities.data[0];
if (!opportunity?.product_price_list_id) throw new Error("No opportunity on that account, or it has no price list.");
console.log("Opportunity:", opportunity.name, opportunity.id);# The account's opportunity, with the price list its line items take their prices from.
opportunities = coevera(
"GET",
"/entities/Opportunities",
params={
"filter[account_relations.account_id]": account_id,
"first": 1,
"load-only": "id,name,product_price_list_id",
},
)
if not opportunities["data"] or not opportunities["data"][0]["product_price_list_id"]:
sys.exit("No opportunity on that account, or it has no price list.")
opportunity = opportunities["data"][0]
print("Opportunity:", opportunity["name"], opportunity["id"])# The account's opportunity, with the price list its line items take their prices from.
opportunity=$(jq -n --arg account "$ACCOUNT_ID" '{
query: "query FindOpportunity($account: String!) { entities { opportunity { getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$account] } } }) { edges { node { id name productPriceListId } } } } } }",
variables: { account: $account }
}' | gql)
echo "$opportunity"
OPPORTUNITY_ID=$(jq -er '.data.entities.opportunity.getByCriteria.edges[0].node.id' <<<"$opportunity")
PRICE_LIST_ID=$(jq -er '.data.entities.opportunity.getByCriteria.edges[0].node.productPriceListId' <<<"$opportunity")// The account's opportunity, with the price list its line items take their prices from.
const opportunities = await gql(
`query FindOpportunity($accountId: String!) {
entities {
opportunity {
getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$accountId] } } }) {
edges { node { id name productPriceListId } }
}
}
}
}`,
{ accountId: account.id },
);
const opportunity = opportunities.entities.opportunity.getByCriteria.edges[0]?.node;
if (!opportunity?.productPriceListId) throw new Error("No opportunity on that account, or it has no price list.");
console.log("Opportunity:", opportunity.name, opportunity.id);# The account's opportunity, with the price list its line items take their prices from.
opportunities = gql(
"""
query FindOpportunity($accountId: String!) {
entities {
opportunity {
getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$accountId] } } }) {
edges { node { id name productPriceListId } }
}
}
}
}
""",
{"accountId": account["id"]},
)
edges = opportunities["entities"]["opportunity"]["getByCriteria"]["edges"]
if not edges or not edges[0]["node"]["productPriceListId"]:
sys.exit("No opportunity on that account, or it has no price list.")
opportunity = edges[0]["node"]
print("Opportunity:", opportunity["name"], opportunity["id"]){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0b000000-0000-4000-a000-000000000001",
"name": "Northwind data platform rollout",
"product_price_list_id": "02000000-0000-4000-a000-000000000001"
}
]
}{
"data": {
"entities": {
"opportunity": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "0b000000-0000-4000-a000-000000000001",
"name": "Northwind data platform rollout",
"productPriceListId": "02000000-0000-4000-a000-000000000001"
}
}
]
}
}
}
}
}3. Create the category
A category groups products in the catalogue. Only name is needed. The response carries the
id the product uses.
category=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/ProductCategories" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d '{ "name": "Analytics services" }')
echo "$category"
CATEGORY_ID=$(jq -er '.data.id' <<<"$category")const category = await coevera("/entities/ProductCategories", {
method: "POST",
body: { name: "Analytics services" },
});
const categoryId: string = category.data.id;
console.log("Category:", category.data.name, categoryId);category = coevera("POST", "/entities/ProductCategories", json={"name": "Analytics services"})
category_id = category["data"]["id"]
print("Category:", category["data"]["name"], category_id)category=$(jq -n '{
query: "mutation CreateCategory($input: CreateProductCategoryInput!) { createProductCategory(input: $input) { productCategory { id name } } }",
variables: { input: { name: "Analytics services" } }
}' | gql)
echo "$category"
CATEGORY_ID=$(jq -er '.data.createProductCategory.productCategory.id' <<<"$category")const category = await gql(
`mutation CreateCategory($input: CreateProductCategoryInput!) {
createProductCategory(input: $input) { productCategory { id name } }
}`,
{ input: { name: "Analytics services" } },
);
const categoryId: string = category.createProductCategory.productCategory.id;
console.log("Category:", category.createProductCategory.productCategory.name, categoryId);category = gql(
"""
mutation CreateCategory($input: CreateProductCategoryInput!) {
createProductCategory(input: $input) { productCategory { id name } }
}
""",
{"input": {"name": "Analytics services"}},
)["createProductCategory"]["productCategory"]
print("Category:", category["name"], category["id"]){
"success": true,
"data": {
"is_delete_protected": false,
"id": "11000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.000000+00:00",
"created": "2026-10-15 09:00:00.000031+00:00",
"parent": null,
"name": "Analytics services",
"parent_id": null,
"revision": 774,
"product_relations": []
}
}{
"data": {
"createProductCategory": {
"productCategory": {
"id": "11000000-0000-4000-a000-000000000001",
"name": "Analytics services"
}
}
}
}4. Create the product
name and unit_symbol are required: without unit_symbol the API answers 422, although the
published spec marks only name as required (Products reference). sku must be unique in the space: a second
product with the same sku is refused with 400 (Column sku must be unique.). allowed_pipelines
defaults to 1, all pipelines.
# name and unit_symbol are required; sku must be unique in the space.
product=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Products" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"name": "Data quality audit",
"sku": "NW-DQ-AUDIT",
"unit_symbol": "audit",
"description": "A review of up to ten data sources, with a findings report.",
"product_category_id": "$CATEGORY_ID"
}
JSON
)
echo "$product"
PRODUCT_ID=$(jq -er '.data.id' <<<"$product")// name and unit_symbol are required; sku must be unique in the space.
const product = await coevera("/entities/Products", {
method: "POST",
body: {
name: "Data quality audit",
sku: "NW-DQ-AUDIT",
unit_symbol: "audit",
description: "A review of up to ten data sources, with a findings report.",
product_category_id: categoryId,
},
});
const productId: string = product.data.id;
console.log("Product:", product.data.name, productId);# name and unit_symbol are required; sku must be unique in the space.
product = coevera(
"POST",
"/entities/Products",
json={
"name": "Data quality audit",
"sku": "NW-DQ-AUDIT",
"unit_symbol": "audit",
"description": "A review of up to ten data sources, with a findings report.",
"product_category_id": category_id,
},
)
product_id = product["data"]["id"]
print("Product:", product["data"]["name"], product_id)# name and unitSymbol are required; sku must be unique in the space.
product=$(jq -n --arg category "$CATEGORY_ID" '{
query: "mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { product { id name sku } } }",
variables: {
input: {
name: "Data quality audit",
sku: "NW-DQ-AUDIT",
unitSymbol: "audit",
description: "A review of up to ten data sources, with a findings report.",
productCategoryId: $category
}
}
}' | gql)
echo "$product"
PRODUCT_ID=$(jq -er '.data.createProduct.product.id' <<<"$product")// name and unitSymbol are required; sku must be unique in the space.
const product = await gql(
`mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) { product { id name sku } }
}`,
{
input: {
name: "Data quality audit",
sku: "NW-DQ-AUDIT",
unitSymbol: "audit",
description: "A review of up to ten data sources, with a findings report.",
productCategoryId: categoryId,
},
},
);
const productId: string = product.createProduct.product.id;
console.log("Product:", product.createProduct.product.name, productId);# name and unitSymbol are required; sku must be unique in the space.
product = gql(
"""
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) { product { id name sku } }
}
""",
{
"input": {
"name": "Data quality audit",
"sku": "NW-DQ-AUDIT",
"unitSymbol": "audit",
"description": "A review of up to ten data sources, with a findings report.",
"productCategoryId": category["id"],
}
},
)["createProduct"]["product"]
print("Product:", product["name"], product["id"]){
"success": true,
"data": {
"is_delete_protected": false,
"id": "09000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.160189+00:00",
"created": "2026-10-15 09:00:00.160234+00:00",
"currency_exchange_rates_list": null,
"picture": null,
"product_category": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ProductCategories/11000000-0000-4000-a000-000000000001",
"product_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ProductTypes/15000000-0000-4000-a000-000000000001",
"allowed_pipelines": 1,
"currency_exchange_rates_list_id": null,
"description": "A review of up to ten data sources, with a findings report.",
"name": "Data quality audit",
"picture_id": null,
"product_category_id": "11000000-0000-4000-a000-000000000001",
"product_type_id": "15000000-0000-4000-a000-000000000001",
"sku": "NW-DQ-AUDIT",
"unit_symbol": "audit",
"revision": 775,
"product_pipeline_relations": [],
"product_pipelines": {
"allowed_pipelines": 1,
"pipelines": []
},
"formatted_name": "Data quality audit",
"oppty_product_relation_type_id": "14000000-0000-4000-a000-000000000001",
"price_lists": [],
"price_schedules": []
}
}Show all 33 linesShow less
{
"data": {
"createProduct": {
"product": {
"id": "09000000-0000-4000-a000-000000000001",
"name": "Data quality audit",
"sku": "NW-DQ-AUDIT"
}
}
}
}5. Find its list price
A new product gets a price of 0 in the price list, as a ProductPriceListPrices row. Find the
row in the opportunity's price list.
# A new product gets a price of 0 in the price list: find its row in the opportunity's list.
price=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/ProductPriceListPrices" \
-d "filter[product_id]=$PRODUCT_ID" \
-d "filter[product_price_list_id]=$PRICE_LIST_ID" \
-d load-only=id,price \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$price"
PRICE_ID=$(jq -er '.data[0].id' <<<"$price")// A new product gets a price of 0 in the price list: find its row in the opportunity's list.
const prices = await coevera(
`/entities/ProductPriceListPrices?filter[product_id]=${productId}&filter[product_price_list_id]=${opportunity.product_price_list_id}&load-only=id,price`,
);
const priceId: string = prices.data[0].id;
console.log("List price row:", priceId, "price", prices.data[0].price);# A new product gets a price of 0 in the price list: find its row in the opportunity's list.
prices = coevera(
"GET",
"/entities/ProductPriceListPrices",
params={
"filter[product_id]": product_id,
"filter[product_price_list_id]": opportunity["product_price_list_id"],
"load-only": "id,price",
},
)
price_id = prices["data"][0]["id"]
print("List price row:", price_id, "price", prices["data"][0]["price"])# A new product gets a price of 0 in the price list: find its row in the opportunity's list.
price=$(jq -n --arg product "$PRODUCT_ID" --arg list "$PRICE_LIST_ID" '{
query: "query FindPrice($product: String!, $list: String!) { entities { productPriceListPrice { getByCriteria(first: 1, filter: { productId: { eq: [$product] }, productPriceListId: { eq: [$list] } }) { edges { node { id price } } } } } }",
variables: { product: $product, list: $list }
}' | gql)
echo "$price"
PRICE_ID=$(jq -er '.data.entities.productPriceListPrice.getByCriteria.edges[0].node.id' <<<"$price")// A new product gets a price of 0 in the price list: find its row in the opportunity's list.
const prices = await gql(
`query FindPrice($productId: String!, $priceListId: String!) {
entities {
productPriceListPrice {
getByCriteria(first: 1, filter: { productId: { eq: [$productId] }, productPriceListId: { eq: [$priceListId] } }) {
edges { node { id price } }
}
}
}
}`,
{ productId, priceListId: opportunity.productPriceListId },
);
const priceRow = prices.entities.productPriceListPrice.getByCriteria.edges[0].node;
console.log("List price row:", priceRow.id, "price", priceRow.price);# A new product gets a price of 0 in the price list: find its row in the opportunity's list.
prices = gql(
"""
query FindPrice($productId: String!, $priceListId: String!) {
entities {
productPriceListPrice {
getByCriteria(
first: 1
filter: { productId: { eq: [$productId] }, productPriceListId: { eq: [$priceListId] } }
) {
edges { node { id price } }
}
}
}
}
""",
{"productId": product["id"], "priceListId": opportunity["productPriceListId"]},
)
price_row = prices["entities"]["productPriceListPrice"]["getByCriteria"]["edges"][0]["node"]
print("List price row:", price_row["id"], "price", price_row["price"]){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIxMzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIxMzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "13000000-0000-4000-a000-000000000001",
"price": 0
}
]
}{
"data": {
"entities": {
"productPriceListPrice": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "13000000-0000-4000-a000-000000000001",
"price": 0
}
}
]
}
}
}
}
}6. Set the list price
Set the row's price to 4500. Line items compute their discount against it.
curl -sS --fail-with-body -X PATCH "$COEVERA_BASE/entities/ProductPriceListPrices/$PRICE_ID" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d '{ "price": 4500 }' >/dev/null
echo "List price set to 4500."await coevera(`/entities/ProductPriceListPrices/${priceId}`, { method: "PATCH", body: { price: 4500 } });
console.log("List price set to 4500.");coevera("PATCH", f"/entities/ProductPriceListPrices/{price_id}", json={"price": 4500})
print("List price set to 4500.")jq -n --arg id "$PRICE_ID" '{
query: "mutation SetPrice($input: UpdateProductPriceListPriceInput!) { updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } } }",
variables: { input: { id: $id, price: 4500 } }
}' | gqlawait gql(
`mutation SetPrice($input: UpdateProductPriceListPriceInput!) {
updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } }
}`,
{ input: { id: priceRow.id, price: 4500 } },
);
console.log("List price set to 4500.");gql(
"""
mutation SetPrice($input: UpdateProductPriceListPriceInput!) {
updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } }
}
""",
{"input": {"id": price_row["id"], "price": 4500}},
)
print("List price set to 4500."){
"success": true,
"data": {
"is_delete_protected": false,
"id": "13000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.538771+00:00",
"created": "2026-10-15 09:00:00.192015+00:00",
"currency": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Currencies/04000000-0000-4000-a000-000000000001",
"product": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Products/09000000-0000-4000-a000-000000000001",
"product_price_list": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ProductPriceLists/02000000-0000-4000-a000-000000000001",
"currency_id": "04000000-0000-4000-a000-000000000001",
"price": 4500,
"product_id": "09000000-0000-4000-a000-000000000001",
"product_price_list_id": "02000000-0000-4000-a000-000000000001",
"settings": {
"access": "All",
"access_roles": null
},
"revision": 776
}
}{
"data": {
"updateProductPriceListPrice": {
"productPriceListPrice": {
"id": "13000000-0000-4000-a000-000000000001",
"price": 4500
}
}
}
}7. Add the line item
Send oppty_id, product_id, quantity and price. Always send quantity: without it, the
line gets a quantity of 1. Without price, the line takes the product's price in the
opportunity's price list, or 0 when the opportunity has none.
# Send price and quantity. The amount and the discount are computed; the API refuses them.
curl -sS --fail-with-body "$COEVERA_BASE/entities/OpptyProductRelations" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"oppty_id": "$OPPORTUNITY_ID",
"product_id": "$PRODUCT_ID",
"quantity": 3,
"price": 4000,
"comment": "Partner rate"
}
JSON
echo// Send price and quantity. The amount and the discount are computed; the API refuses them.
const lineItem = await coevera("/entities/OpptyProductRelations", {
method: "POST",
body: { oppty_id: opportunity.id, product_id: productId, quantity: 3, price: 4000, comment: "Partner rate" },
});
console.log("Line item:", lineItem.data.id);# Send price and quantity. The amount and the discount are computed; the API refuses them.
line_item = coevera(
"POST",
"/entities/OpptyProductRelations",
json={
"oppty_id": opportunity["id"],
"product_id": product_id,
"quantity": 3,
"price": 4000,
"comment": "Partner rate",
},
)
print("Line item:", line_item["data"]["id"])# Send price and quantity. The amount and the discount are computed; the API refuses them.
jq -n --arg oppty "$OPPORTUNITY_ID" --arg product "$PRODUCT_ID" '{
query: "mutation AddLineItem($input: CreateOpptyProductRelationInput!) { createOpptyProductRelation(input: $input) { opptyProductRelation { id } } }",
variables: { input: { opptyId: $oppty, productId: $product, quantity: 3, price: 4000, comment: "Partner rate" } }
}' | gql// Send price and quantity. The amount and the discount are computed; the API refuses them.
const lineItem = await gql(
`mutation AddLineItem($input: CreateOpptyProductRelationInput!) {
createOpptyProductRelation(input: $input) { opptyProductRelation { id } }
}`,
{ input: { opptyId: opportunity.id, productId, quantity: 3, price: 4000, comment: "Partner rate" } },
);
console.log("Line item:", lineItem.createOpptyProductRelation.opptyProductRelation.id);# Send price and quantity. The amount and the discount are computed; the API refuses them.
line_item = gql(
"""
mutation AddLineItem($input: CreateOpptyProductRelationInput!) {
createOpptyProductRelation(input: $input) { opptyProductRelation { id } }
}
""",
{
"input": {
"opptyId": opportunity["id"],
"productId": product["id"],
"quantity": 3,
"price": 4000,
"comment": "Partner rate",
}
},
)
print("Line item:", line_item["createOpptyProductRelation"]["opptyProductRelation"]["id"]){
"success": true,
"data": {
"is_delete_protected": false,
"id": "12000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.702328+00:00",
"created": "2026-10-15 09:00:00.702360+00:00",
"currency_exchange_rates_list": null,
"online_form_field": null,
"online_form": null,
"oppty": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Opportunities/0b000000-0000-4000-a000-000000000001",
"oppty_product_relation_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/OpptyProductRelationTypes/14000000-0000-4000-a000-000000000001",
"paired_oppty_product_relation": null,
"product": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Products/09000000-0000-4000-a000-000000000001",
"quote": null,
"amount": 0,
"comment": "Partner rate",
"currency_exchange_rates_list_id": null,
"online_form_field_id": null,
"online_form_id": null,
"oppty_id": "0b000000-0000-4000-a000-000000000001",
"oppty_product_relation_type_id": "14000000-0000-4000-a000-000000000001",
"paired_oppty_product_relation_id": null,
"price": 4000,
"product_id": "09000000-0000-4000-a000-000000000001",
"quantity": 3,
"quote_id": null,
"section_id": null,
"sort_order": 0,
"revision": 777,
"revenue_schedule": null,
"discount_value": -1500,
"discount_percentage": 11.11111111111111,
"modified_by_user": null
}
}Show all 37 linesShow less
{
"data": {
"createOpptyProductRelation": {
"opptyProductRelation": {
"id": "12000000-0000-4000-a000-000000000001"
}
}
}
}Warning
Never send a discount. discount_value and discount_percentage are read-only and computed: a
REST create with either one is refused with 400 (ERROR_PARAMETER_INVALID), and the GraphQL
input has no such fields. Set the price you agreed instead.
8. Read it back
Read the line item back with its product. The create response above still shows an amount of
0; the read-back shows the computed values.
curl -sS --fail-with-body -G "$COEVERA_BASE/entities/OpptyProductRelations" \
-d "filter[oppty_id]=$OPPORTUNITY_ID" \
-d "filter[product_id]=$PRODUCT_ID" \
-d expand=product \
-d load-only=id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET"const lines = await coevera(
`/entities/OpptyProductRelations?${new URLSearchParams({
"filter[oppty_id]": opportunity.id,
"filter[product_id]": productId,
expand: "product",
"load-only": "id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku",
})}`,
);
console.log(JSON.stringify(lines.data, null, 2));lines = coevera(
"GET",
"/entities/OpptyProductRelations",
params={
"filter[oppty_id]": opportunity["id"],
"filter[product_id]": product_id,
"expand": "product",
"load-only": "id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku",
},
)
print(json.dumps(lines["data"], indent=2))jq -n --arg oppty "$OPPORTUNITY_ID" --arg product "$PRODUCT_ID" '{
query: "query ReadBack($oppty: String!, $product: String!) { entities { opptyProductRelation { getByCriteria(filter: { opptyId: { eq: [$oppty] }, productId: { eq: [$product] } }) { edges { node { id quantity price amount discountValue discountPercentage product { name sku productCategory { name } } } } } } } }",
variables: { oppty: $oppty, product: $product }
}' | gqlconst lines = await gql(
`query ReadBack($opportunityId: String!, $productId: String!) {
entities {
opptyProductRelation {
getByCriteria(filter: { opptyId: { eq: [$opportunityId] }, productId: { eq: [$productId] } }) {
edges {
node {
id quantity price amount discountValue discountPercentage
product { name sku productCategory { name } }
}
}
}
}
}
}`,
{ opportunityId: opportunity.id, productId },
);
console.log(JSON.stringify(lines.entities.opptyProductRelation.getByCriteria.edges, null, 2));lines = gql(
"""
query ReadBack($opportunityId: String!, $productId: String!) {
entities {
opptyProductRelation {
getByCriteria(filter: { opptyId: { eq: [$opportunityId] }, productId: { eq: [$productId] } }) {
edges {
node {
id quantity price amount discountValue discountPercentage
product { name sku productCategory { name } }
}
}
}
}
}
}
""",
{"opportunityId": opportunity["id"], "productId": product["id"]},
)
print(json.dumps(lines["entities"]["opptyProductRelation"]["getByCriteria"]["edges"], indent=2)){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIxMjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIxMjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "12000000-0000-4000-a000-000000000001",
"product": {
"name": "Data quality audit",
"sku": "NW-DQ-AUDIT"
},
"amount": 12000,
"price": 4000,
"quantity": 3,
"discount_value": -1500,
"discount_percentage": 11.11111111111111
}
]
}{
"data": {
"entities": {
"opptyProductRelation": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "12000000-0000-4000-a000-000000000001",
"quantity": 3,
"price": 4000,
"amount": 12000,
"discountValue": -1500,
"discountPercentage": 11.11111111111111,
"product": {
"name": "Data quality audit",
"sku": "NW-DQ-AUDIT",
"productCategory": {
"name": "Analytics services"
}
}
}
}
]
}
}
}
}
}Show all 29 linesShow less
What to check
- Step 2 returns
product_price_list_id. Without one, a line item sent without apricegets a price of 0. - Steps 3 and 4 answer 201 (GraphQL: 200), and step 5 finds one price row of 0 for the new product.
- Step 8 returns
quantity: 3,price: 4000andamount: 12000, withdiscount_percentage11.11 anddiscount_value-1500: the API reports a price below the list price as a negativediscount_value. - Run the program twice and the second run stops at step 4: the
skuis taken. To make it safe to repeat, look the product up byskufirst and reuse it.
Clean up
The program leaves the category, the product and the line item in your space. Delete the line
item (POST /entities/OpptyProductRelations/batch-delete) before the product: deleting a product
leaves its line items active. Then delete the product, whose price rows go with it, and the
category, each with its own batch-delete. Deletes are soft, and a deleted product's sku can
be used again.
Limits
- The program adds one line item. Several lines go in one call with
POST /entities/OpptyProductRelations/batch-modifyandrollback-method=1. - It does not set the opportunity's
value. When the opportunity'sis_value_auto_calculateistrue, adding, changing or deleting a line item setsvalueto the sum of the line items'amount; withfalse, the default for an opportunity created through the API, line items leavevaluealone. - It prices the product in the opportunity's price list only.