---
title: Pipeline report
description: 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.
canonical: https://developers.coevera.com/recipes/pipeline-report/
section: recipes
verification: behaviour
lastVerified: 2026-10-15
publisher: Coevera
---

# Pipeline report

**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_API_KEY` and `$COEVERA_API_SECRET`.                     |

The programs need `curl` and `jq`, Node.js 18 or later, or Python 3.9 or later with `requests`.
[Authentication](/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/Steps`](/reference/rest/steps/#list),
[`GET /entities/Opportunities`](/reference/rest/opportunities/#list); GraphQL
[`StepRepository.getByCriteria`](/reference/graphql/step/#q-getByCriteria),
[`OpportunityRepository.getByCriteria`](/reference/graphql/opportunity/#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) {
  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;
}
```

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

**REST · cURL**

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

**REST · TypeScript**

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

**REST · Python**

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

**GraphQL · cURL**

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

**GraphQL · TypeScript**

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

**GraphQL · Python**

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

**Recorded response · REST · 200 OK**

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

**Recorded response · GraphQL · 200 OK**

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

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

## 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` (what `URLSearchParams` and `requests` send) 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](/concepts/querying/#filter)).
- **Expand.** `expand=step,primary_account` returns the step and the account as objects, and
  `load-only` keeps only the fields the report prints, nested ones included (`step.name`).
  Without `expand`, a relation comes back as its URL. In GraphQL, the query selects `step` and
  `primaryAccount` directly.
- **Paginate.** `first=2` asks for two opportunities per page, so the cursor shows; send up to
  100\. While `page_info.has_next_page` is true, send `page_info.end_cursor` as `after`,
  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 fields `pageInfo`, `endCursor` and `hasNextPage`.

`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:

**REST · cURL**

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

**REST · TypeScript**

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

**REST · Python**

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

**GraphQL · cURL**

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

**GraphQL · TypeScript**

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

**GraphQL · Python**

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

**Recorded response · REST · 200 OK**

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

**Recorded response · GraphQL · 200 OK**

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

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

**Recorded response · REST · 200 OK**

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

**Recorded response · GraphQL · 200 OK**

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

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

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

**REST · cURL**

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

**REST · TypeScript**

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

**REST · Python**

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

**GraphQL · cURL**

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

**GraphQL · TypeScript**

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

**GraphQL · Python**

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

```text
Validate     1  value 18500  weighted 5550
Offer        1  value 48000  weighted 28800
Close        1  value 32000  weighted 32000
```

## What to check

- Step 1 returns the steps in pipeline order. An empty `data` means no pipeline has that name.
- Every page answers 200 with at most two rows, the last one with `has_next_page: false`
  (GraphQL `hasNextPage`), and the rows add up to `total` (GraphQL `totalCount`).
- Every row carries `step.name` and `primary_account.name`. A row whose step is a URL means the
  request lost its `expand`.
- 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, `OpportunityFilterInput` has no `closingDate` field, so a GraphQL report cannot
  narrow by closing date on the server. REST can, with `filter[closing_date]=2026-11-01,2027-02-28`
  and `filter-op[closing_date]=between`.
