Knowledge Graph API

Last updated: August 19, 2026

How to use the Knowledge Graph API to query engineering data in Span

This guide explains how to read engineering data out of Span for a script, dashboard, or agent. Typical examples include pull request cycle time, merged pull request volume, and deployment counts.

The Knowledge Graph API (/next) is built around two calls:

  1. Metadata. Discover which assets, fields, and metric IDs your token can access.

  2. Query. Request those fields and metrics with filters and a time window.

This API is dynamic. Some fields and response shapes are subject to change. Re-fetch metadata for your own token before building or updating an integration, and do not hard-code identifiers from another organization.

We strongly recommend the Span Claude/Cursor Skill to explore the Knowledge Graph API. It calls this same API and handles metadata discovery, metric selection, and query construction for you, so you can ask questions in natural language instead of writing requests by hand. The rest of this guide is for cases where you need to call the API directly.

The machine-readable OpenAPI specification is available at https://api.span.app/docs-next.

Base URL and endpoints

All requests use the base URL https://api.span.app.

Method

Endpoint

Purpose

GET

/next/metadata/assets

Discover assets, fields, relations, dimensions, and metrics

POST

/next/assets/query

Query one asset for records or aggregated metrics

The older catalog API at https://api.span.app/docs is separate. Use /next for Knowledge Graph queries.

Getting started

1. Get a token

Authenticate with a bearer token:

Authorization: Bearer <access token>

2. Fetch metadata for the asset you need

curl -s -X GET "https://api.span.app/next/metadata/assets?facade=PullRequest" \
  -H "Authorization: Bearer <access token>"

The response describes the asset. The example below is trimmed for readability; the full response contains every field, relation, dimension, and metric your token can access. The exact structure is subject to change, so read it at runtime rather than hard-coding it.

401 response indicates a missing or invalid token. A 403 indicates the token lacks permission for the requested data.

{
  "data": [
    {
      "name": "PullRequest",
      "fields": [
        { "name": "title", "type": ["string"], "required": true },
        {
          "name": "extMergedAt",
          "description": "The date-time the pull request was merged, in ISO 8601 format.",
          "type": ["date-time"],
          "required": false
        }
      ],
      "relations": [
        { "name": "Author", "type": "Person" },
        { "name": "Repository", "type": "Repository" }
      ],
      "dimensions": [
        { "name": "ts", "label": "Timestamp", "description": "Primary timestamp of the record" }
      ],
      "metrics": [
        {
          "id": "988a86d4-1da7-40ed-b429-1b65e7c63865",
          "label": "Time from first commit to merge",
          "description": "Time from pull request first commit to merge.",
          "unit": "seconds",
          "category": "cycle-time",
          "aggregation": {
            "default": "avg",
            "available": ["p50", "p75", "p90", "max", "avg"]
          }
        }
      ]
    }
  ],
  "meta": {
    "page": {
      "endCursor": null,
      "startCursor": null,
      "hasNextPage": false,
      "hasPreviousPage": false,
      "pageSize": 1
    },
    "total": 1
  }
}

From this response, copy:

  • Field and relation names for select and filters, using the exact spelling (for example title, or Repository to reach repositoryName).

  • A metric's id (UUID). Do not rely on the label alone, and note the unit and available aggregation values.

3. Run a query

Send a POST /next/assets/query with a body built from that metadata (see the use cases below). Read annotations.unit in the response (secondscountpercentage_as_ratio, and similar) before displaying values.

For list-style queries, paginate with ?limit=25&after=<endCursor> (limit defaults to 10, maximum 100). Groups-mode results are not paginated.

Common use cases

The metric IDs and field names in these examples come from real metadata (the PullRequest sample above, plus the Person and Team excerpts at the end of this article). They are illustrative and subject to change. Resolve the IDs from your own /next/metadata/assets response before production use.

Use case A: recent pull requests in a repository, with cycle time

Goal: audit or export pull requests for one repository, sorted by time to merge.

Approach: query PullRequest. Select title, author, and repository name. Filter on Repository.repositoryName. Attach the metric labeled "Time from first commit to merge".

curl -s -X POST "https://api.span.app/next/assets/query?limit=25" \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{
    "select": [
      "PullRequest.title",
      "PullRequest.Author.email",
      "PullRequest.Repository.repositoryName"
    ],
    "filters": [
      {
        "field": "PullRequest.Repository.repositoryName",
        "operator": "=",
        "value": "myrepo"
      }
    ],
    "metrics": [
      {
        "metricId": "988a86d4-1da7-40ed-b429-1b65e7c63865",
        "responseKey": "PullRequest.cycleTime"
      }
    ],
    "timeDimension": {
      "timeRange": { "startTime": "2026-07-01", "endTime": "2026-07-31" }
    },
    "order": { "field": "PullRequest.cycleTime", "direction": "desc" }
  }'

This returns one row per pull request. PullRequest.cycleTime is reported in seconds (confirm via annotations). To fetch the next page, resend the same body with &after=<endCursor>.

Use case B: merged pull requests broken down by engineer tenure

Goal: compare throughput across tenure bands, or any other person dimension.

Approach: use mode: "groups" on Person. Select only the dimension (personTenure). Attach the "Total PRs merged" metric.

curl -s -X POST "https://api.span.app/next/assets/query" \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "groups",
    "select": ["Person.personTenure"],
    "filters": [],
    "metrics": [
      {
        "metricId": "01fcd390-ae25-4728-b55c-686e103e3b7d",
        "responseKey": "totalMergedPRs"
      }
    ],
    "timeDimension": {
      "timeRange": { "startTime": "2026-01-01", "endTime": "2026-03-31" }
    }
  }'

This returns one row per tenure bucket. Add "granularity": "week" under timeDimension to receive a weekly series instead of a single value per bucket.

Groups mode always requires at least one dimension in select (the field you are grouping by), plus at least one metric. If you want a single organization-wide series with no breakdown, there is no dimension to group by, so do not use mode: "groups" — see Use case D instead.

Related variations: for organization-wide cycle time, query Team with Team.name = "Organization" (confirm the root team name from metadata) and the same cycle-time metric. For a weekly trend for one person, query Person with an email filter and granularity: "week".

Use case C: production deploys for a team and its sub-teams

Goal: count deployments across a team tree. Note that Team.name = "X" returns only that team's own deploys and does not roll up sub-teams.

Approach: resolve Team.path, then use groups mode with Team.groupPath and the DESCENDANT_OF operator, together with the "Deployments" metric.

curl -s -X POST "https://api.span.app/next/assets/query" \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{
    "select": ["Team.name", "Team.path"],
    "filters": [
      { "field": "Team.name", "operator": "=", "value": "Platform" }
    ],
    "metrics": []
  }'
curl -s -X POST "https://api.span.app/next/assets/query" \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "groups",
    "select": ["Team.groupPath"],
    "filters": [
      {
        "field": "Team.groupPath",
        "operator": "DESCENDANT_OF",
        "value": "<Team.path from the previous response>"
      }
    ],
    "metrics": [
      {
        "metricId": "c3377814-3805-4803-89ba-6f77bb3a8907",
        "responseKey": "deploys"
      }
    ],
    "timeDimension": {
      "timeRange": { "startTime": "2026-04-01", "endTime": "2026-04-30" }
    }
  }'

For organization-wide deploys, use the root team's Team.path (the path with no . separator) rather than Team.name = "Organization" alone.

To send deployment events into Span (the write path), use the DORA deployments API.

Use case D: an organization-wide metric over time (no breakdown)

Goal: track a single metric for the whole organization as a time series — for example, weekly PR throughput sampled month by month over the last six months — with no per-team or per-person breakdown.

Approach: this is not a groups query. Groups mode aggregates by a dimension and requires one; a whole-org series has no dimension to group by. Instead, query the Team facade filtered to the root (organization) team, attach the metric, and set granularity. Resolve the root team from metadata (its Team.path has no . separator; Team.name is usually your organization's name).

curl -s -X POST "https://api.span.app/next/assets/query" \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{
    "select": ["Team.name"],
    "filters": [
      { "field": "Team.name", "operator": "=", "value": "Organization" }
    ],
    "metrics": [
      {
        "metricId": "<your metric id from metadata>",
        "responseKey": "prRate"
      }
    ],
    "timeDimension": {
      "timeRange": { "startTime": "2026-02-01", "endTime": "2026-08-01" },
      "granularity": "month"
    }
  }'

This returns a single row for the organization with prRate as a monthly { time, value } series. Confirm the metric's unit via annotations. If the metric is an additive count and you need sub-teams rolled up under the root, use Team.groupPath with DESCENDANT_OF from the root path instead (see Use case C). To break the same metric down later (by team, repo, or person), switch to that asset with mode: "groups" and a dimension (see Use cases B and C).

Building other payloads from metadata

When your use case is not covered above, build the request from metadata as follows.

You need

Look in metadata for

Put it in the query as

Columns to return

fields[].namerelations[]

select"Asset.field" or "Asset.Relation.field"

Filters

The same names

filters{ "field", "operator", "value" } (may be [], but must be present)

A metric

metrics[].id (use label and unit to choose)

metrics{ "metricId", "responseKey?" } (may be [], but must be present)

Time window or series

dimensions[]

timeDimension.timeRange; optional granularity; optional dimensionName

Breakdown by dimension

dimensions[].name

mode: "groups" with select containing only those dimensions (at least one is required)

The most common operators are =INCONTAINS (catalog fields only), and DESCENDANT_OF (team trees).

Match the grain of the question to the asset. A breakdown "by repository, team, or person" should query that asset. Querying PullRequest for a count by repository returns one row per pull request.

Optional helpers: POST /next/assets/query/schema accepts the same body and returns the column schema without running the full query. Send Accept: text/csv on the query endpoint to receive CSV.

Additional metadata used in the examples

These are illustrative excerpts for the other assets referenced above (the PullRequest sample appears in step 2). They are from one organization and token and are subject to change; re-fetch metadata before relying on these IDs. The Repository name field used in paths is repositoryName, not name.

Person

{
  "name": "Person",
  "fields": [{ "name": "email", "type": ["string"] }],
  "dimensions": [{ "name": "personTenure", "label": "Tenure" }],
  "metrics": [
    {
      "id": "01fcd390-ae25-4728-b55c-686e103e3b7d",
      "label": "Total PRs merged",
      "unit": "count"
    }
  ]
}

Team

{
  "name": "Team",
  "fields": [
    { "name": "name", "type": ["string"] },
    { "name": "path", "type": ["string"] }
  ],
  "dimensions": [
    {
      "name": "groupPath",
      "label": "Team path",
      "description": "Filter with DESCENDANT_OF to scope a metric to a team and all of its subteams."
    }
  ],
  "metrics": [
    {
      "id": "c3377814-3805-4803-89ba-6f77bb3a8907",
      "label": "Deployments",
      "unit": "count"
    }
  ]
}

Troubleshooting

Symptom

Likely fix

400 unknown field

Re-check spelling against metadata (repositoryName, not name)

400 groups mode needs a dimension (e.g. "In groups mode, select must contain at least one dimension")

Add a dimension to select, or drop mode: "groups" for a plain metric or time series (see Use case D). Groups mode also requires at least one metric and does not support order.

Empty data

Widen timeRange or relax filters before assuming an auth issue

Incorrect deploy totals

Use groupPath with DESCENDANT_OF, not only Team.name

Unexpected values

Read annotations.unit (seconds versus hours; ratios are often 0 to 1)

401 or 403

Issue a new token, or correct the service account's permission group

Related