RecipesPipeline report
Pipeline report
Read every opportunity in a pipeline with filters, expand and cursor pagination together, and total the value and weighted value per step, in REST or GraphQL.
Answer
Read the pipeline's steps, then the opportunities with filter[step_id] set to those ids (a raw comma is an OR), expand=step,primary_account and load-only, two per page, following page_info.end_cursor (URL-encoded) until has_next_page is false. Total the value per step and weight it by the step's percent.
Northwind's sales lead wants the pipeline on one screen: every opportunity in the Default pipeline, by step, with its account, and per step the count, the value and the value weighted by the step's probability. The program only reads, so it is safe to run against any space.
Before you start
| Permissions | Read Only Access to Opportunities and Accounts on the Records tab, and to Pipelines on the Administration tab. |
|---|---|
validation-level |
Not used: the program only reads. |
| Batch behaviour | No batches. |
| 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/Steps,
GET /entities/Opportunities; GraphQL
StepRepository.getByCriteria,
OpportunityRepository.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) {
const res = await fetch(`${base}${path}`, { headers: { Authorization: `Basic ${auth}` } });
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(path, params=None):
response = session.get(f"{BASE}{path}", params=params, timeout=30)
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 pipeline's steps
Read the steps of the pipeline by its name, in sort_order. Each step's percent is its
probability; the report weights the value with it. The program reads the pipeline called Default
pipeline; use the name of yours.
# The pipeline's steps in order, with the probability (percent) of each.
steps=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Steps" \
--data-urlencode "filter[pipeline.name]=Default pipeline" \
-d order-by=sort_order -d load-only=id,name,percent \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$steps"
STEP_IDS=$(jq -er '[.data[].id] | join(",")' <<<"$steps")// The pipeline's steps in order, with the probability (percent) of each.
const steps = await coevera(
`/entities/Steps?${new URLSearchParams({ "filter[pipeline.name]": "Default pipeline", "order-by": "sort_order", "load-only": "id,name,percent" })}`,
);
if (!steps.data.length) throw new Error("No pipeline with that name, or it has no steps.");
console.log("Steps:", steps.data.map((s: { name: string }) => s.name).join(", "));# The pipeline's steps in order, with the probability (percent) of each.
steps = coevera(
"/entities/Steps",
{"filter[pipeline.name]": "Default pipeline", "order-by": "sort_order", "load-only": "id,name,percent"},
)["data"]
if not steps:
sys.exit("No pipeline with that name, or it has no steps.")
print("Steps:", ", ".join(step["name"] for step in steps))# The pipeline's steps in order, with the probability (percent) of each.
steps=$(jq -n '{
query: "{ entities { step { getByCriteria(filter: { pipeline: { name: { eq: [\"Default pipeline\"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name percent } } } } } }"
}' | gql)
echo "$steps"
STEPS=$(jq -c '[.data.entities.step.getByCriteria.edges[].node]' <<<"$steps")// The pipeline's steps in order, with the probability (percent) of each.
const stepsResult = await gql(`{
entities {
step {
getByCriteria(filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) {
edges { node { id name percent } }
}
}
}
}`);
const steps: { id: string; name: string; percent: number }[] = stepsResult.entities.step.getByCriteria.edges.map(
(e: { node: { id: string; name: string; percent: number } }) => e.node,
);
if (!steps.length) throw new Error("No pipeline with that name, or it has no steps.");
console.log("Steps:", steps.map((s) => s.name).join(", "));# The pipeline's steps in order, with the probability (percent) of each.
steps_result = gql("""
{
entities {
step {
getByCriteria(filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) {
edges { node { id name percent } }
}
}
}
}
""")
steps = [edge["node"] for edge in steps_result["entities"]["step"]["getByCriteria"]["edges"]]
if not steps:
sys.exit("No pipeline with that name, or it has no steps.")
print("Steps:", ", ".join(step["name"] for step in steps)){
"success": true,
"total": 3,
"page_info": {
"start_cursor": "WzAsICIwNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WzIsICIwNzAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDMiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "07000000-0000-4000-a000-000000000001",
"name": "Validate",
"percent": 30
},
{
"id": "07000000-0000-4000-a000-000000000002",
"name": "Offer",
"percent": 60
},
{
"id": "07000000-0000-4000-a000-000000000003",
"name": "Close",
"percent": 100
}
]
}Show all 27 linesShow less
{
"data": {
"entities": {
"step": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "07000000-0000-4000-a000-000000000001",
"name": "Validate",
"percent": 30
}
},
{
"node": {
"id": "07000000-0000-4000-a000-000000000002",
"name": "Offer",
"percent": 60
}
},
{
"node": {
"id": "07000000-0000-4000-a000-000000000003",
"name": "Close",
"percent": 100
}
}
]
}
}
}
}
}Show all 33 linesShow less
2. Read every page
One query combines the three tools of a list call:
- Filter.
filter[step_id]with the step ids, separated by raw commas: a raw comma is an OR, while%2C(whatURLSearchParamsandrequestssend) is a literal comma and matches nothing, so the programs write this filter into the URL themselves. A dotted filter reaches one related record, not its relations:filter[step.pipeline.name]answers 400 (code 117), so the report filters on the steps it read in step 1 (querying). - Expand.
expand=step,primary_accountreturns the step and the account as objects, andload-onlykeeps only the fields the report prints, nested ones included (step.name). Withoutexpand, a relation comes back as its URL. In GraphQL, the query selectsstepandprimaryAccountdirectly. - Paginate.
first=2asks for two opportunities per page, so the cursor shows; send up to 100. Whilepage_info.has_next_pageis true, sendpage_info.end_cursorasafter, URL-encoded: a cursor is base64 and a raw+in it would arrive as a space. GraphQL takes the cursor as a variable, and calls the fieldspageInfo,endCursorandhasNextPage.
order-by=closing_date (GraphQL orderBy: [{ closingDate: Asc }]) keeps the order stable from
page to page. These are the two pages of the recorded run:
# Opportunities in those steps: a raw comma between the ids is an OR. Step and account come
# expanded, and load-only keeps the rows small. Two per page here, so the cursor shows.
rows='[]'
cursor=""
while :; do
# --data-urlencode encodes the cursor: a raw "+" in it would arrive as a space.
page=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Opportunities" \
-d "filter[step_id]=$STEP_IDS" \
-d order-by=closing_date -d first=2 -d expand=step,primary_account \
-d load-only=id,name,closing_date,status,value,step.name,primary_account.name \
${cursor:+--data-urlencode "after=$cursor"} \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$page"
rows=$(jq -c --argjson rows "$rows" '$rows + .data' <<<"$page")
next=$(jq -r 'if .page_info.has_next_page then .page_info.end_cursor else "" end' <<<"$page")
# An unchanged cursor would ask for the same page again: stop rather than loop forever.
if [ -z "$next" ] || [ "$next" = "$cursor" ]; then break; fi
cursor="$next"
done// Opportunities in those steps: a raw comma between the ids is an OR (%2C, which
// URLSearchParams sends, would be a literal comma). Step and account come expanded, and
// load-only keeps the rows small. Two per page here, so the cursor shows; send up to 100.
const stepIds = steps.data.map((s: { id: string }) => s.id).join(",");
const query =
`filter[step_id]=${stepIds}&order-by=closing_date&first=2&expand=step,primary_account` +
"&load-only=id,name,closing_date,status,value,step.name,primary_account.name";
interface Row {
name: string;
closing_date: string;
value: { base_value: number } | null;
step: { name: string } | null;
primary_account: { name: string } | null;
}
const rows: Row[] = [];
let cursor: string | null = null;
do {
// encodeURIComponent: a raw "+" in the cursor would arrive as a space.
const page = await coevera(`/entities/Opportunities?${query}${cursor ? `&after=${encodeURIComponent(cursor)}` : ""}`);
rows.push(...page.data);
const next: string | null = page.page_info.has_next_page ? page.page_info.end_cursor : null;
// An unchanged cursor would ask for the same page again: stop rather than loop forever.
cursor = next === cursor ? null : next;
} while (cursor);
console.log(rows.length, "opportunities");# Opportunities in those steps: a raw comma between the ids is an OR. requests would send the
# comma as %2C, a literal comma, so the filter goes into the path by hand. Step and account come
# expanded, and load-only keeps the rows small. Two per page here, so the cursor shows.
step_ids = ",".join(step["id"] for step in steps)
params = {
"order-by": "closing_date",
"first": 2,
"expand": "step,primary_account",
"load-only": "id,name,closing_date,status,value,step.name,primary_account.name",
}
rows = []
cursor = None
while True:
# requests encodes the cursor: a raw "+" in it would arrive as a space.
page = coevera(f"/entities/Opportunities?filter[step_id]={step_ids}", {**params, "after": cursor})
rows.extend(page["data"])
next_cursor = page["page_info"]["end_cursor"] if page["page_info"]["has_next_page"] else None
# An unchanged cursor would ask for the same page again: stop rather than loop forever.
if not next_cursor or next_cursor == cursor:
break
cursor = next_cursor
print(len(rows), "opportunities")# Opportunities in those steps, with step and account in the same query. Two per page here,
# so the cursor shows; ask for up to 100.
rows='[]'
cursor=""
while :; do
page=$(jq -n --argjson steps "$STEPS" --arg after "$cursor" '{
query: "query Page($steps: [String]!, $after: String) { entities { opportunity { getByCriteria(first: 2, after: $after, filter: { stepId: { eq: $steps } }, orderBy: [{ closingDate: Asc }]) { totalCount pageInfo { hasNextPage endCursor } edges { node { name closingDate status value { baseValue } step { name } primaryAccount { name } } } } } } }",
variables: { steps: [$steps[].id], after: (if $after == "" then null else $after end) }
}' | gql)
echo "$page"
rows=$(jq -c --argjson rows "$rows" '$rows + [.data.entities.opportunity.getByCriteria.edges[].node]' <<<"$page")
next=$(jq -r '.data.entities.opportunity.getByCriteria.pageInfo | if .hasNextPage then .endCursor else "" end' <<<"$page")
# An unchanged cursor would ask for the same page again: stop rather than loop forever.
if [ -z "$next" ] || [ "$next" = "$cursor" ]; then break; fi
cursor="$next"
done// Opportunities in those steps, with step and account in the same query. Two per page here,
// so the cursor shows; ask for up to 100.
interface Row {
name: string;
closingDate: string;
status: string;
value: { baseValue: number } | null;
step: { name: string } | null;
primaryAccount: { name: string } | null;
}
const PAGE = `query Page($steps: [String]!, $after: String) {
entities {
opportunity {
getByCriteria(first: 2, after: $after, filter: { stepId: { eq: $steps } }, orderBy: [{ closingDate: Asc }]) {
totalCount
pageInfo { hasNextPage endCursor }
edges { node { name closingDate status value { baseValue } step { name } primaryAccount { name } } }
}
}
}
}`;
const rows: Row[] = [];
let cursor: string | null = null;
do {
const data = await gql(PAGE, { steps: steps.map((s) => s.id), after: cursor });
const page = data.entities.opportunity.getByCriteria;
rows.push(...page.edges.map((e: { node: Row }) => e.node));
const next: string | null = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
// An unchanged cursor would ask for the same page again: stop rather than loop forever.
cursor = next === cursor ? null : next;
} while (cursor);
console.log(rows.length, "opportunities");# Opportunities in those steps, with step and account in the same query. Two per page here,
# so the cursor shows; ask for up to 100.
PAGE = """
query Page($steps: [String]!, $after: String) {
entities {
opportunity {
getByCriteria(first: 2, after: $after, filter: { stepId: { eq: $steps } }, orderBy: [{ closingDate: Asc }]) {
totalCount
pageInfo { hasNextPage endCursor }
edges { node { name closingDate status value { baseValue } step { name } primaryAccount { name } } }
}
}
}
}
"""
rows = []
cursor = None
while True:
page = gql(PAGE, {"steps": [step["id"] for step in steps], "after": cursor})["entities"]["opportunity"]["getByCriteria"]
rows.extend(edge["node"] for edge in page["edges"])
next_cursor = page["pageInfo"]["endCursor"] if page["pageInfo"]["hasNextPage"] else None
# An unchanged cursor would ask for the same page again: stop rather than loop forever.
if not next_cursor or next_cursor == cursor:
break
cursor = next_cursor
print(len(rows), "opportunities"){
"success": true,
"total": 3,
"page_info": {
"start_cursor": "WyIyMDI2LTExLTIwIiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMSJd",
"end_cursor": "WyIyMDI2LTEyLTE1IiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMiJd",
"has_previous_page": false,
"has_next_page": true
},
"data": [
{
"id": "0b000000-0000-4000-a000-000000000001",
"step": {
"name": "Close"
},
"closing_date": "2026-11-20",
"name": "Wide World Importers dashboard renewal",
"value": {
"base_value": 32000,
"currency_id": "04000000-0000-4000-a000-000000000001",
"value_foreign": 32000
},
"primary_account": {
"name": "Wide World Importers"
},
"status": 3
},
{
"id": "0b000000-0000-4000-a000-000000000002",
"step": {
"name": "Offer"
},
"closing_date": "2026-12-15",
"name": "Northwind data platform rollout",
"value": {
"base_value": 48000,
"currency_id": "04000000-0000-4000-a000-000000000001",
"value_foreign": 48000
},
"primary_account": {
"name": "Northwind Analytics"
},
"status": 1
}
]
}Show all 46 linesShow less
{
"data": {
"entities": {
"opportunity": {
"getByCriteria": {
"totalCount": 3,
"pageInfo": {
"hasNextPage": true,
"endCursor": "WyIyMDI2LTEyLTE1IiwgIjAwMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMSJd"
},
"edges": [
{
"node": {
"name": "Wide World Importers dashboard renewal",
"closingDate": "2026-11-20",
"status": "Won",
"value": {
"baseValue": 32000
},
"step": {
"name": "Close"
},
"primaryAccount": {
"name": "Wide World Importers"
}
}
},
{
"node": {
"name": "Northwind data platform rollout",
"closingDate": "2026-12-15",
"status": "Open",
"value": {
"baseValue": 48000
},
"step": {
"name": "Offer"
},
"primaryAccount": {
"name": "Northwind Analytics"
}
}
}
]
}
}
}
}
}Show all 49 linesShow less
{
"success": true,
"total": 3,
"page_info": {
"start_cursor": "WyIyMDI3LTAxLTI5IiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMyJd",
"end_cursor": "WyIyMDI3LTAxLTI5IiwgIjBiMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMyJd",
"has_previous_page": true,
"has_next_page": false
},
"data": [
{
"id": "0b000000-0000-4000-a000-000000000003",
"step": {
"name": "Validate"
},
"closing_date": "2027-01-29",
"name": "Contoso fleet telemetry pilot",
"value": {
"base_value": 18500,
"currency_id": "04000000-0000-4000-a000-000000000001",
"value_foreign": 18500
},
"primary_account": {
"name": "Contoso Freight"
},
"status": 1
}
]
}Show all 29 linesShow less
{
"data": {
"entities": {
"opportunity": {
"getByCriteria": {
"totalCount": 3,
"pageInfo": {
"hasNextPage": false,
"endCursor": "WyIyMDI3LTAxLTI5IiwgIjAwMDAwMDAwLTAwMDAtNDAwMC1hMDAwLTAwMDAwMDAwMDAwMiJd"
},
"edges": [
{
"node": {
"name": "Contoso fleet telemetry pilot",
"closingDate": "2027-01-29",
"status": "Open",
"value": {
"baseValue": 18500
},
"step": {
"name": "Validate"
},
"primaryAccount": {
"name": "Contoso Freight"
}
}
}
]
}
}
}
}
}Show all 33 linesShow less
status is 1 Open, 2 Lost or 3 Won in REST, and Open, Lost or Won in GraphQL: an
opportunity in the pipeline's last step is won.
3. Build the report
No request: the program groups the rows by step, and multiplies each step's value by its
percent.
# Per step: how many, their value, and the value weighted by the step's probability.
# An expanded relation is an object; without expand, the API sends its URL instead (hence .name?).
jq -r --argjson rows "$rows" '.data[] as $step
| [$rows[] | select(.step.name? == $step.name)] as $in
| ([$in[] | .value.base_value // 0] | add // 0) as $value
| "\($step.name) \($in | length) value \($value) weighted \($value * $step.percent / 100)",
($in[] | " \(.closing_date) \(.name) (\(.primary_account.name? // ""))")' <<<"$steps"// Per step: how many, their value, and the value weighted by the step's probability.
for (const step of steps.data) {
const inStep = rows.filter((o) => o.step?.name === step.name);
const value = inStep.reduce((sum, o) => sum + (o.value?.base_value ?? 0), 0);
console.log(`${step.name.padEnd(10)} ${String(inStep.length).padStart(3)} value ${value} weighted ${(value * step.percent) / 100}`);
for (const o of inStep) console.log(` ${o.closing_date} ${o.name} (${o.primary_account?.name})`);
}# Per step: how many, their value, and the value weighted by the step's probability.
def name_of(relation):
"""An expanded relation is an object; without expand, the API sends its URL instead."""
return relation.get("name") if isinstance(relation, dict) else None
for step in steps:
in_step = [o for o in rows if name_of(o.get("step")) == step["name"]]
value = sum((o.get("value") or {}).get("base_value") or 0 for o in in_step)
print(f"{step['name']:<10} {len(in_step):>3} value {value} weighted {value * step['percent'] / 100}")
for o in in_step:
print(f" {o['closing_date']} {o['name']} ({name_of(o.get('primary_account'))})")# Per step: how many, their value, and the value weighted by the step's probability.
jq -rn --argjson steps "$STEPS" --argjson rows "$rows" '$steps[] as $step
| [$rows[] | select(.step.name == $step.name)] as $in
| ([$in[] | .value.baseValue // 0] | add // 0) as $value
| "\($step.name) \($in | length) value \($value) weighted \($value * $step.percent / 100)",
($in[] | " \(.closingDate) \(.name) (\(.primaryAccount.name), \(.status))")'// Per step: how many, their value, and the value weighted by the step's probability.
for (const step of steps) {
const inStep = rows.filter((o) => o.step?.name === step.name);
const value = inStep.reduce((sum, o) => sum + (o.value?.baseValue ?? 0), 0);
console.log(`${step.name.padEnd(10)} ${String(inStep.length).padStart(3)} value ${value} weighted ${(value * step.percent) / 100}`);
for (const o of inStep) console.log(` ${o.closingDate} ${o.name} (${o.primaryAccount?.name}, ${o.status})`);
}# Per step: how many, their value, and the value weighted by the step's probability.
for step in steps:
in_step = [o for o in rows if (o.get("step") or {}).get("name") == step["name"]]
value = sum((o.get("value") or {}).get("baseValue") or 0 for o in in_step)
print(f"{step['name']:<10} {len(in_step):>3} value {value} weighted {value * step['percent'] / 100}")
for o in in_step:
account = (o.get("primaryAccount") or {}).get("name")
print(f" {o['closingDate']} {o['name']} ({account}, {o['status']})")In the recorded run, the report reads:
Validate 1 value 18500 weighted 5550
Offer 1 value 48000 weighted 28800
Close 1 value 32000 weighted 32000What to check
- Step 1 returns the steps in pipeline order. An empty
datameans no pipeline has that name. - Every page answers 200 with at most two rows, the last one with
has_next_page: false(GraphQLhasNextPage), and the rows add up tototal(GraphQLtotalCount). - Every row carries
step.nameandprimary_account.name. A row whose step is a URL means the request lost itsexpand. - Run it twice and you get the same report, unless the pipeline changed in between: the program writes nothing.
Clean up
Nothing to clean up: the program only reads.
Limits
- A page holds at most 100 records, and 30 when you do not set
first. - The report sums
value.base_value, in the space's base currency. - In GraphQL,
OpportunityFilterInputhas noclosingDatefield, so a GraphQL report cannot narrow by closing date on the server. REST can, withfilter[closing_date]=2026-11-01,2027-02-28andfilter-op[closing_date]=between.