---
title: Products and line items
description: Create a product category and a product, set its list price, and add it to an opportunity as a line item with a price and a quantity, in REST or GraphQL.
canonical: https://developers.coevera.com/recipes/products-and-line-items/
section: recipes
verification: behaviour
lastVerified: 2026-10-15
publisher: Coevera
---

# Products and line items

**Answer:** Create the category, then the product with name, unit_symbol and a unique sku. Set its price in the opportunity's price list, then add an OpptyProductRelations line with oppty_id, product_id, price and quantity. Never send a discount: discount_value and discount_percentage are computed against the list price, and REST refuses them with 400.

Northwind Analytics starts selling a **Data quality audit**. Create it in a new **Analytics
services** category, give it a list price of 4500, and add three audits at a partner rate of 4000
to Northwind's open opportunity. You end with a line item whose amount and discount the API
computed.

## Before you start

|                    |                                                                                                                                                                               |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Permissions        | **Full Access** to **Products & Prices** on the **Administration** tab, **Full Access** to **Opportunities** and **Read Only Access** to **Accounts** on the **Records** tab. |
| `validation-level` | The default (`0`, full validation).                                                                                                                                           |
| Batch behaviour    | No batches: every write is one record.                                                                                                                                        |
| Context            | Region and space ID come from the context bar. Credentials come from `$COEVERA_API_KEY` and `$COEVERA_API_SECRET`.                                                            |

A line item (`OpptyProductRelations`, in the app a product on the opportunity) holds the
product, the `quantity` and the per-unit `price`. The API computes the rest: `amount`, and
`discount_value` and `discount_percentage` against the product's price in the opportunity's
price list (`product_price_list_id`).

The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`.
[Authentication](/get-started/authentication/) shows where the credentials come from, and
[Permissions and scopes](/get-started/permissions-and-scopes/) where to set the permissions.

The operations this recipe calls, in the API reference: REST
[`GET /entities/Accounts`](/reference/rest/accounts/#list),
[`GET /entities/Opportunities`](/reference/rest/opportunities/#list),
[`POST /entities/ProductCategories`](/reference/rest/product-categories/#create),
[`POST /entities/Products`](/reference/rest/products/#create),
[`GET /entities/ProductPriceListPrices`](/reference/rest/product-price-list-prices/#list),
[`PATCH /entities/ProductPriceListPrices/{id}`](/reference/rest/product-price-list-prices/#update),
[`POST /entities/OpptyProductRelations`](/reference/rest/oppty-product-relations/#create),
[`GET /entities/OpptyProductRelations`](/reference/rest/oppty-product-relations/#list); GraphQL
[`AccountRepository.getByCriteria`](/reference/graphql/account/#q-getByCriteria),
[`OpportunityRepository.getByCriteria`](/reference/graphql/opportunity/#q-getByCriteria),
[`createProductCategory`](/reference/graphql/product-category/#m-createProductCategory),
[`createProduct`](/reference/graphql/product/#m-createProduct),
[`ProductPriceListPriceRepository.getByCriteria`](/reference/graphql/product-price-list-price/#q-getByCriteria),
[`updateProductPriceListPrice`](/reference/graphql/product-price-list-price/#m-updateProductPriceListPrice),
[`createOpptyProductRelation`](/reference/graphql/oppty-product-relation/#m-createOpptyProductRelation),
[`OpptyProductRelationRepository.getByCriteria`](/reference/graphql/oppty-product-relation/#q-getByCriteria).

## Set up a helper

Every step below uses this setup. Export the credentials first:

```bash
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
```

**REST · cURL**

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

**REST · TypeScript**

```ts
const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

async function coevera(path: string, init: { method?: string; body?: unknown } = {}) {
  const res = await fetch(`${base}${path}`, {
    method: init.method ?? "GET",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${String(body.message).trim()}`);
  return body;
}
```

**REST · Python**

```python
BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])


def coevera(method, path, **kwargs):
    response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs)
    body = response.json()
    if not response.ok:
        sys.exit(f"HTTP {response.status_code} {body.get('name')}: {str(body.get('message')).strip()}")
    return body
```

**GraphQL · cURL**

```bash
COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public"

# gql: POST the GraphQL request read from stdin. GraphQL reports errors in the body, often
# with HTTP 200, so fail on those too.
gql() {
  local response
  response=$(curl -sS --fail-with-body "$COEVERA_GRAPHQL" \
    -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
    -H "Content-Type: application/json" \
    -d @-)
  if jq -e '.errors' <<<"$response" >/dev/null; then
    echo "$response" >&2
    return 1
  fi
  echo "$response"
}
```

**GraphQL · TypeScript**

```ts
const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

// gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200.
async function gql(query: string, variables: object = {}) {
  const res = await fetch(endpoint, {
    method: "POST",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  const body = await res.json();
  if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`);
  return body.data;
}
```

**GraphQL · Python**

```python
ENDPOINT = (
    "https://{region}.api.coevera.com/api/v100/app/space/"
    "{space_id}/graphql/public"
)
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])


def gql(query, variables=None):
    """One GraphQL request. GraphQL reports errors in the body, often with HTTP 200."""
    response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30)
    body = response.json()
    if not response.ok or body.get("errors"):
        sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}")
    return body["data"]
```

## 1. Find the account

Look the account up by its e-mail address.

**REST · cURL**

```bash
account=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \
  --data-urlencode "filter[email1]=northwind@example.com" \
  -d first=1 -d load-only=id,name \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$account"
ACCOUNT_ID=$(jq -er '.data[0].id' <<<"$account")
```

**REST · TypeScript**

```ts
const accounts = await coevera(
  `/entities/Accounts?${new URLSearchParams({ "filter[email1]": "northwind@example.com", first: "1", "load-only": "id,name" })}`,
);
const accountId: string | undefined = accounts.data[0]?.id;
if (!accountId) throw new Error("No account with that e-mail.");
console.log("Account:", accounts.data[0].name, accountId);
```

**REST · Python**

```python
accounts = coevera(
    "GET",
    "/entities/Accounts",
    params={"filter[email1]": "northwind@example.com", "first": 1, "load-only": "id,name"},
)
if not accounts["data"]:
    sys.exit("No account with that e-mail.")
account_id = accounts["data"][0]["id"]
print("Account:", accounts["data"][0]["name"], account_id)
```

**GraphQL · cURL**

```bash
account=$(jq -n '{
  query: "{ entities { account { getByCriteria(first: 1, filter: { email1: { eq: [\"northwind@example.com\"] } }) { edges { node { id name } } } } } }"
}' | gql)
echo "$account"
ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$account")
```

**GraphQL · TypeScript**

```ts
const accounts = await gql(`{
  entities {
    account {
      getByCriteria(first: 1, filter: { email1: { eq: ["northwind@example.com"] } }) {
        edges { node { id name } }
      }
    }
  }
}`);
const account = accounts.entities.account.getByCriteria.edges[0]?.node;
if (!account) throw new Error("No account with that e-mail.");
console.log("Account:", account.name, account.id);
```

**GraphQL · Python**

```python
accounts = gql("""
{
  entities {
    account {
      getByCriteria(first: 1, filter: { email1: { eq: ["northwind@example.com"] } }) {
        edges { node { id name } }
      }
    }
  }
}
""")
edges = accounts["entities"]["account"]["getByCriteria"]["edges"]
if not edges:
    sys.exit("No account with that e-mail.")
account = edges[0]["node"]
print("Account:", account["name"], account["id"])
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "total": 1,
  "page_info": {
    "start_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "end_cursor": "WyIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "has_previous_page": false,
    "has_next_page": false
  },
  "data": [
    {
      "id": "0a000000-0000-4000-a000-000000000001",
      "name": "Northwind Analytics"
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "account": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0a000000-0000-4000-a000-000000000001",
                "name": "Northwind Analytics"
              }
            }
          ]
        }
      }
    }
  }
}
```

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

## 2. Find the opportunity and its price list

Find the account's opportunity through its account relations. `product_price_list_id` names the
price list its line items take their prices from; the next steps price the product there.

**REST · cURL**

```bash
# The account's opportunity, with the price list its line items take their prices from.
opportunity=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \
  -d "filter[account_relations.account_id]=$ACCOUNT_ID" \
  -d first=1 -d load-only=id,name,product_price_list_id \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$opportunity"
OPPORTUNITY_ID=$(jq -er '.data[0].id' <<<"$opportunity")
PRICE_LIST_ID=$(jq -er '.data[0].product_price_list_id' <<<"$opportunity")
```

**REST · TypeScript**

```ts
// The account's opportunity, with the price list its line items take their prices from.
const opportunities = await coevera(
  `/entities/Opportunities?filter[account_relations.account_id]=${accountId}&first=1&load-only=id,name,product_price_list_id`,
);
const opportunity = opportunities.data[0];
if (!opportunity?.product_price_list_id) throw new Error("No opportunity on that account, or it has no price list.");
console.log("Opportunity:", opportunity.name, opportunity.id);
```

**REST · Python**

```python
# The account's opportunity, with the price list its line items take their prices from.
opportunities = coevera(
    "GET",
    "/entities/Opportunities",
    params={
        "filter[account_relations.account_id]": account_id,
        "first": 1,
        "load-only": "id,name,product_price_list_id",
    },
)
if not opportunities["data"] or not opportunities["data"][0]["product_price_list_id"]:
    sys.exit("No opportunity on that account, or it has no price list.")
opportunity = opportunities["data"][0]
print("Opportunity:", opportunity["name"], opportunity["id"])
```

**GraphQL · cURL**

```bash
# The account's opportunity, with the price list its line items take their prices from.
opportunity=$(jq -n --arg account "$ACCOUNT_ID" '{
  query: "query FindOpportunity($account: String!) { entities { opportunity { getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$account] } } }) { edges { node { id name productPriceListId } } } } } }",
  variables: { account: $account }
}' | gql)
echo "$opportunity"
OPPORTUNITY_ID=$(jq -er '.data.entities.opportunity.getByCriteria.edges[0].node.id' <<<"$opportunity")
PRICE_LIST_ID=$(jq -er '.data.entities.opportunity.getByCriteria.edges[0].node.productPriceListId' <<<"$opportunity")
```

**GraphQL · TypeScript**

```ts
// The account's opportunity, with the price list its line items take their prices from.
const opportunities = await gql(
  `query FindOpportunity($accountId: String!) {
    entities {
      opportunity {
        getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$accountId] } } }) {
          edges { node { id name productPriceListId } }
        }
      }
    }
  }`,
  { accountId: account.id },
);
const opportunity = opportunities.entities.opportunity.getByCriteria.edges[0]?.node;
if (!opportunity?.productPriceListId) throw new Error("No opportunity on that account, or it has no price list.");
console.log("Opportunity:", opportunity.name, opportunity.id);
```

**GraphQL · Python**

```python
# The account's opportunity, with the price list its line items take their prices from.
opportunities = gql(
    """
    query FindOpportunity($accountId: String!) {
      entities {
        opportunity {
          getByCriteria(first: 1, filter: { accountRelations: { accountId: { eq: [$accountId] } } }) {
            edges { node { id name productPriceListId } }
          }
        }
      }
    }
    """,
    {"accountId": account["id"]},
)
edges = opportunities["entities"]["opportunity"]["getByCriteria"]["edges"]
if not edges or not edges[0]["node"]["productPriceListId"]:
    sys.exit("No opportunity on that account, or it has no price list.")
opportunity = edges[0]["node"]
print("Opportunity:", opportunity["name"], opportunity["id"])
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "total": 1,
  "page_info": {
    "start_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "end_cursor": "WyIwYjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "has_previous_page": false,
    "has_next_page": false
  },
  "data": [
    {
      "id": "0b000000-0000-4000-a000-000000000001",
      "name": "Northwind data platform rollout",
      "product_price_list_id": "02000000-0000-4000-a000-000000000001"
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "opportunity": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0b000000-0000-4000-a000-000000000001",
                "name": "Northwind data platform rollout",
                "productPriceListId": "02000000-0000-4000-a000-000000000001"
              }
            }
          ]
        }
      }
    }
  }
}
```

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

## 3. Create the category

A category groups products in the catalogue. Only `name` is needed. The response carries the
`id` the product uses.

**REST · cURL**

```bash
category=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/ProductCategories" \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Analytics services" }')
echo "$category"
CATEGORY_ID=$(jq -er '.data.id' <<<"$category")
```

**REST · TypeScript**

```ts
const category = await coevera("/entities/ProductCategories", {
  method: "POST",
  body: { name: "Analytics services" },
});
const categoryId: string = category.data.id;
console.log("Category:", category.data.name, categoryId);
```

**REST · Python**

```python
category = coevera("POST", "/entities/ProductCategories", json={"name": "Analytics services"})
category_id = category["data"]["id"]
print("Category:", category["data"]["name"], category_id)
```

**GraphQL · cURL**

```bash
category=$(jq -n '{
  query: "mutation CreateCategory($input: CreateProductCategoryInput!) { createProductCategory(input: $input) { productCategory { id name } } }",
  variables: { input: { name: "Analytics services" } }
}' | gql)
echo "$category"
CATEGORY_ID=$(jq -er '.data.createProductCategory.productCategory.id' <<<"$category")
```

**GraphQL · TypeScript**

```ts
const category = await gql(
  `mutation CreateCategory($input: CreateProductCategoryInput!) {
    createProductCategory(input: $input) { productCategory { id name } }
  }`,
  { input: { name: "Analytics services" } },
);
const categoryId: string = category.createProductCategory.productCategory.id;
console.log("Category:", category.createProductCategory.productCategory.name, categoryId);
```

**GraphQL · Python**

```python
category = gql(
    """
    mutation CreateCategory($input: CreateProductCategoryInput!) {
      createProductCategory(input: $input) { productCategory { id name } }
    }
    """,
    {"input": {"name": "Analytics services"}},
)["createProductCategory"]["productCategory"]
print("Category:", category["name"], category["id"])
```

**Recorded response · REST · 201 Created**

```json
{
  "success": true,
  "data": {
    "is_delete_protected": false,
    "id": "11000000-0000-4000-a000-000000000001",
    "is_deleted": false,
    "modified": "2026-10-15 09:00:00.000000+00:00",
    "created": "2026-10-15 09:00:00.000031+00:00",
    "parent": null,
    "name": "Analytics services",
    "parent_id": null,
    "revision": 774,
    "product_relations": []
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "createProductCategory": {
      "productCategory": {
        "id": "11000000-0000-4000-a000-000000000001",
        "name": "Analytics services"
      }
    }
  }
}
```

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

## 4. Create the product

`name` and `unit_symbol` are required: without `unit_symbol` the API answers 422, although the
published spec marks only `name` as required ([Products reference](/reference/rest/products/#create)). `sku` must be unique in the space: a second
product with the same `sku` is refused with 400 (`Column sku must be unique.`). `allowed_pipelines`
defaults to `1`, all pipelines.

**REST · cURL**

```bash
# name and unit_symbol are required; sku must be unique in the space.
product=$(curl -sS --fail-with-body "$COEVERA_BASE/entities/Products" \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
  -H "Content-Type: application/json" \
  -d @- <<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")
```

**REST · TypeScript**

```ts
// 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);
```

**REST · Python**

```python
# 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)
```

**GraphQL · cURL**

```bash
# 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")
```

**GraphQL · TypeScript**

```ts
// 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);
```

**GraphQL · Python**

```python
# 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"])
```

**Recorded response · REST · 201 Created**

```json
{
  "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": []
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "createProduct": {
      "product": {
        "id": "09000000-0000-4000-a000-000000000001",
        "name": "Data quality audit",
        "sku": "NW-DQ-AUDIT"
      }
    }
  }
}
```

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

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

**REST · cURL**

```bash
# 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")
```

**REST · TypeScript**

```ts
// 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);
```

**REST · Python**

```python
# 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"])
```

**GraphQL · cURL**

```bash
# 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")
```

**GraphQL · TypeScript**

```ts
// 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);
```

**GraphQL · Python**

```python
# 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"])
```

**Recorded response · REST · 200 OK**

```json
{
  "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
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "productPriceListPrice": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "13000000-0000-4000-a000-000000000001",
                "price": 0
              }
            }
          ]
        }
      }
    }
  }
}
```

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

## 6. Set the list price

Set the row's `price` to 4500. Line items compute their discount against it.

**REST · cURL**

```bash
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."
```

**REST · TypeScript**

```ts
await coevera(`/entities/ProductPriceListPrices/${priceId}`, { method: "PATCH", body: { price: 4500 } });
console.log("List price set to 4500.");
```

**REST · Python**

```python
coevera("PATCH", f"/entities/ProductPriceListPrices/{price_id}", json={"price": 4500})
print("List price set to 4500.")
```

**GraphQL · cURL**

```bash
jq -n --arg id "$PRICE_ID" '{
  query: "mutation SetPrice($input: UpdateProductPriceListPriceInput!) { updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } } }",
  variables: { input: { id: $id, price: 4500 } }
}' | gql
```

**GraphQL · TypeScript**

```ts
await gql(
  `mutation SetPrice($input: UpdateProductPriceListPriceInput!) {
    updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } }
  }`,
  { input: { id: priceRow.id, price: 4500 } },
);
console.log("List price set to 4500.");
```

**GraphQL · Python**

```python
gql(
    """
    mutation SetPrice($input: UpdateProductPriceListPriceInput!) {
      updateProductPriceListPrice(input: $input) { productPriceListPrice { id price } }
    }
    """,
    {"input": {"id": price_row["id"], "price": 4500}},
)
print("List price set to 4500.")
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "data": {
    "is_delete_protected": false,
    "id": "13000000-0000-4000-a000-000000000001",
    "is_deleted": false,
    "modified": "2026-10-15 09:00:00.538771+00:00",
    "created": "2026-10-15 09:00:00.192015+00:00",
    "currency": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Currencies/04000000-0000-4000-a000-000000000001",
    "product": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Products/09000000-0000-4000-a000-000000000001",
    "product_price_list": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/ProductPriceLists/02000000-0000-4000-a000-000000000001",
    "currency_id": "04000000-0000-4000-a000-000000000001",
    "price": 4500,
    "product_id": "09000000-0000-4000-a000-000000000001",
    "product_price_list_id": "02000000-0000-4000-a000-000000000001",
    "settings": {
      "access": "All",
      "access_roles": null
    },
    "revision": 776
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "updateProductPriceListPrice": {
      "productPriceListPrice": {
        "id": "13000000-0000-4000-a000-000000000001",
        "price": 4500
      }
    }
  }
}
```

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

## 7. Add the line item

Send `oppty_id`, `product_id`, `quantity` and `price`. Always send `quantity`: without it, the
line gets a quantity of 1. Without `price`, the line takes the product's price in the
opportunity's price list, or 0 when the opportunity has none.

**REST · cURL**

```bash
# Send price and quantity. The amount and the discount are computed; the API refuses them.
curl -sS --fail-with-body "$COEVERA_BASE/entities/OpptyProductRelations" \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "oppty_id": "$OPPORTUNITY_ID",
  "product_id": "$PRODUCT_ID",
  "quantity": 3,
  "price": 4000,
  "comment": "Partner rate"
}
JSON
echo
```

**REST · TypeScript**

```ts
// 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);
```

**REST · Python**

```python
# 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"])
```

**GraphQL · cURL**

```bash
# 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
```

**GraphQL · TypeScript**

```ts
// 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);
```

**GraphQL · Python**

```python
# 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"])
```

**Recorded response · REST · 201 Created**

```json
{
  "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
  }
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "createOpptyProductRelation": {
      "opptyProductRelation": {
        "id": "12000000-0000-4000-a000-000000000001"
      }
    }
  }
}
```

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

> **Warning:** Never send a discount. `discount_value` and `discount_percentage` are read-only and computed: a
> REST create with either one is refused with 400 (`ERROR_PARAMETER_INVALID`), and the GraphQL
> input has no such fields. Set the `price` you agreed instead.

## 8. Read it back

Read the line item back with its product. The create response above still shows an `amount` of
0; the read-back shows the computed values.

**REST · cURL**

```bash
curl -sS --fail-with-body -G "$COEVERA_BASE/entities/OpptyProductRelations" \
  -d "filter[oppty_id]=$OPPORTUNITY_ID" \
  -d "filter[product_id]=$PRODUCT_ID" \
  -d expand=product \
  -d load-only=id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku \
  -u "$COEVERA_API_KEY:$COEVERA_API_SECRET"
```

**REST · TypeScript**

```ts
const lines = await coevera(
  `/entities/OpptyProductRelations?${new URLSearchParams({
    "filter[oppty_id]": opportunity.id,
    "filter[product_id]": productId,
    expand: "product",
    "load-only": "id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku",
  })}`,
);
console.log(JSON.stringify(lines.data, null, 2));
```

**REST · Python**

```python
lines = coevera(
    "GET",
    "/entities/OpptyProductRelations",
    params={
        "filter[oppty_id]": opportunity["id"],
        "filter[product_id]": product_id,
        "expand": "product",
        "load-only": "id,quantity,price,amount,discount_value,discount_percentage,product.name,product.sku",
    },
)
print(json.dumps(lines["data"], indent=2))
```

**GraphQL · cURL**

```bash
jq -n --arg oppty "$OPPORTUNITY_ID" --arg product "$PRODUCT_ID" '{
  query: "query ReadBack($oppty: String!, $product: String!) { entities { opptyProductRelation { getByCriteria(filter: { opptyId: { eq: [$oppty] }, productId: { eq: [$product] } }) { edges { node { id quantity price amount discountValue discountPercentage product { name sku productCategory { name } } } } } } } }",
  variables: { oppty: $oppty, product: $product }
}' | gql
```

**GraphQL · TypeScript**

```ts
const lines = await gql(
  `query ReadBack($opportunityId: String!, $productId: String!) {
    entities {
      opptyProductRelation {
        getByCriteria(filter: { opptyId: { eq: [$opportunityId] }, productId: { eq: [$productId] } }) {
          edges {
            node {
              id quantity price amount discountValue discountPercentage
              product { name sku productCategory { name } }
            }
          }
        }
      }
    }
  }`,
  { opportunityId: opportunity.id, productId },
);
console.log(JSON.stringify(lines.entities.opptyProductRelation.getByCriteria.edges, null, 2));
```

**GraphQL · Python**

```python
lines = gql(
    """
    query ReadBack($opportunityId: String!, $productId: String!) {
      entities {
        opptyProductRelation {
          getByCriteria(filter: { opptyId: { eq: [$opportunityId] }, productId: { eq: [$productId] } }) {
            edges {
              node {
                id quantity price amount discountValue discountPercentage
                product { name sku productCategory { name } }
              }
            }
          }
        }
      }
    }
    """,
    {"opportunityId": opportunity["id"], "productId": product["id"]},
)
print(json.dumps(lines["entities"]["opptyProductRelation"]["getByCriteria"]["edges"], indent=2))
```

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "total": 1,
  "page_info": {
    "start_cursor": "WyIxMjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "end_cursor": "WyIxMjAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
    "has_previous_page": false,
    "has_next_page": false
  },
  "data": [
    {
      "id": "12000000-0000-4000-a000-000000000001",
      "product": {
        "name": "Data quality audit",
        "sku": "NW-DQ-AUDIT"
      },
      "amount": 12000,
      "price": 4000,
      "quantity": 3,
      "discount_value": -1500,
      "discount_percentage": 11.11111111111111
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "opptyProductRelation": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "12000000-0000-4000-a000-000000000001",
                "quantity": 3,
                "price": 4000,
                "amount": 12000,
                "discountValue": -1500,
                "discountPercentage": 11.11111111111111,
                "product": {
                  "name": "Data quality audit",
                  "sku": "NW-DQ-AUDIT",
                  "productCategory": {
                    "name": "Analytics services"
                  }
                }
              }
            }
          ]
        }
      }
    }
  }
}
```

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

## What to check

- Step 2 returns `product_price_list_id`. Without one, a line item sent without a `price` gets
  a price of 0.
- Steps 3 and 4 answer **201** (GraphQL: 200), and step 5 finds one price row of 0 for the new
  product.
- Step 8 returns `quantity: 3`, `price: 4000` and `amount: 12000`, with `discount_percentage`
  11.11 and `discount_value` -1500: the API reports a price below the list price as a negative
  `discount_value`.
- Run the program twice and the second run stops at step 4: the `sku` is taken. To make it safe
  to repeat, look the product up by `sku` first and reuse it.

## Clean up

The program leaves the category, the product and the line item in your space. Delete the line
item (`POST /entities/OpptyProductRelations/batch-delete`) before the product: deleting a product
leaves its line items active. Then delete the product, whose price rows go with it, and the
category, each with its own `batch-delete`. Deletes are soft, and a deleted product's `sku` can
be used again.

## Limits

- The program adds one line item. Several lines go in one call with
  `POST /entities/OpptyProductRelations/batch-modify` and `rollback-method=1`.
- It does not set the opportunity's `value`. When the opportunity's `is_value_auto_calculate` is
  `true`, adding, changing or deleting a line item sets `value` to the sum of the line items'
  `amount`; with `false`, the default for an opportunity created through the API, line items leave
  `value` alone.
- It prices the product in the opportunity's price list only.
