Developers
Set your API contextPick a region and space ID once. Every sample fills in your base URL.
Where do I find these?

In Coevera: open your space's API application and choose Show Access. It shows the space ID and the base URL, whose host starts with your region.

From the API: if you know the space ID, the discovery endpoint GET /api/discovery/spaces/{space_id} returns its region and base URLs, without credentials.

Only the region and space ID are kept, in this browser. Never paste a key or password here.

More in the quickstart

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.

  • 10 min
  • REST and GraphQL
  • cURL · TypeScript · Python
  • Verified in the docs sandbox on 2026-10-15

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

Shell
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
COEVERA_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 body
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"
}
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))
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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 (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).
  • 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:

# 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")
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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
200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "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:

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.