---
id: query-v1
slug: /apis/query-v1
title: 'Query API (query.v1)'
description: 'Retrieve Agility assets with flexible filtering, sorting, and pagination'
sidebar_label: 'Query API (query.v1)'
sidebar_position: 2
tags: ['api', 'query', 'read', 'filter']
---

The Query API retrieves Agility assets using a structured request body rather than a query
string. It is the most convenient endpoint for reading data when you are composing a query in
code, because the request is JSON or YAML and the response is JSON.

## Overview

- **Endpoint**: `POST ~/query.v1`
- **Authentication**: required — see [Authentication](/getting-started/authentication) for the
  supported schemes
- **Content-Type**: `application/json` (YAML is also accepted)
- **Use cases**: asset lookups, filtered reports, backlog exports, workflow automation

## Key Features

- **Two filtering parameters that are not interchangeable** — `where` is equality shorthand
  only; `filter` carries the full expression language. They are named the opposite way round
  from `rest-1.v1`, which is the single most common source of confusion between the two APIs.
  See the warning below the parameter table.
- **Nested projections** — select related attributes such as `Status.Name` and `Owners.Name`,
  and use a nested object as a subquery.
- **Sorting, grouping and paging** — `page` takes a **named mapping** here
  (`{"start": 0, "size": 50}`), unlike `rest-1.v1`'s positional `page=<size>,<start>`.
- **Multiple independent queries** in a single request, returning multiple result sets.

:::danger Always send `page` — there is no default limit

Omitting `page` does not return a sensible first page; it returns **every** matching asset.
A query with no `page` and no `where` will attempt to read the entire asset type out of your
instance, and omitting `select` still returns a per-type default attribute set rather than
nothing.

This is the most common cause of self-inflicted performance problems in Agility integrations.
See [Query size and performance](/guides/query-size-and-performance).

:::

## Request Structure

### Basic Query

```json
{
  "from": "Story",
  "select": ["Number", "Name", "Status.Name", "Scope.Name"],
  "where": {
    "Number": "S-02574"
  }
}
```

### Complete Query Options

```json
{
  "from": "Story",
  "select": [
    "Number",
    "Name",
    "Description",
    "Status",
    "Status.Name",
    "Scope.Name",
    "Owners.Name",
    "Owners.ID",
    "ChangeDate",
    "CreateDate"
  ],
  "where": {
    "Scope": "Scope:1338"
  },
  "filter": ["Status.Name='Ready'", "AssetState!='Dead'"],
  "sort": ["+ChangeDate", "-CreateDate"],
  "page": {
    "start": 0,
    "size": 50
  }
}
```

## Request Parameters

| Field       | Required | Type             | Description                                                                                                    |
| ----------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------- |
| `from`      | Yes      | string           | Asset type to query (`Story`, `Defect`, `Task`, `Test`, etc.)                                                   |
| `select`    | Yes      | array            | Attributes to retrieve; a nested object is a subquery. `sel` is an alias for the key                            |
| `where`     | No       | object           | Equality shorthand only — `{"Number": "S-123"}`. **Not** the filter language                                    |
| `filter`    | No       | string or array  | [Filter expressions](/reference/query-grammar/filter-expressions) — the same language as `where` on `rest-1.v1` |
| `with`      | No       | object           | [Filter context](/reference/query-grammar/filter-context): variable name to a scalar or list                     |
| `sort`      | No       | array            | Sort order (`+Field` ascending, `-Field` descending)                                                           |
| `page`      | No       | object           | Pagination with `start` and `size` — **when present, both are required**; see below                             |
| `group`     | No       | array            | Group the results - see [Grouping](#grouping)                                                                  |
| `find`      | No       | string           | Text to search for                                                                                             |
| `findin`    | No       | array            | Attributes `find` searches; defaults to Name and ShortName                                                      |
| `asof`      | No       | string           | A moment number or a datetime                                                                                  |
| `deleted`   | No       | boolean          | Include deleted assets                                                                                         |
| `needTotal` | No       | boolean          | Accepted but has no effect here: a `query.v1` response is a bare result array with no `total` or other metadata. For a result count, use the Bulk API's [query command](/apis/asset-bulk/queries), whose `resultMetaData` honors it |

The parser accepts exactly these keys — `update` and `execute` are silently
stripped, and any other unrecognised key is rejected.

:::warning `as` aliases parse but do nothing

The parser accepts `"Story as s"` in `from` and `"Name as StoryTitle"` in
`select` entries, then discards the alias. The response key is still `Name`,
and the `from` alias cannot be referenced anywhere. Do not build on either
form.

:::

:::warning `page` needs both `size` and `start`

`{"page": {"size": 50}}` is a server error (HTTP 500): `start` is not
defaulted on this endpoint. This differs from the Bulk API's
[query command](/apis/asset-bulk/queries), where `start` defaults to `0`.
Send both keys.

:::

:::note You may see the `IsDeleted` filter hack in existing code
Older `query.v1` and `api/asset` integrations include deleted assets with
`filter: ["IsDeleted='True','False'"]` rather than `deleted: true` — asserting
both values defeats the server's automatic exclusion of deleted assets. Note that
`IsDeleted` also selects back as the **string** `"True"`, not a boolean. Prefer
`deleted: true` in new code. See
[the `IsDeleted` hack](/reference/query-grammar/filter-expressions#isdeleted-hack).
:::

:::warning `where` means something different here than on `rest-1.v1`
On `query.v1`, `where` is a plain equality mapping and the full filter language
lives under `filter`. On `rest-1.v1` there is no `filter` parameter at all and
`where` **is** the filter language. So `filter: ["ToDo='0'"]` here is
`?where=ToDo='0'` there. See the
[full comparison table](/reference/query-grammar#which-surface-uses-what).

A command may carry **both** — they combine as AND — but prefer one style per
query: anything `where` can express, `filter` can too, and a reader should not
have to merge two clauses to know what a query matches.

`where` is equality and nothing else. Putting an operator in the value —
`where: { Estimate: ">5" }` — does **not** work: the server quotes the whole
value, producing `Estimate='>5'`, which is neither valid nor a
greater-than test. Any comparison (`>`, `<`, `!=`, `>=`) belongs in `filter`:
`filter: ["Estimate>'5'"]`.

The one value that is not quoted is a `$`-prefixed
[reserved variable](/reference/query-grammar/filter-context#reserved-variables):
`where: { ID: "$Search" }` passes through as-is, so a `where` can match against
the current search results. Every other value is treated as a literal.
:::

:::danger Putting the operator in the key silently returns the wrong rows

Do not try to sneak the operator onto the attribute name either. `where` builds
each term as `key = 'value'`, so `where: { "Estimate>": "5" }` becomes the token
`Estimate>='5'` — and that is **accepted, returns rows, and is wrong**. Two
errors compound with no error raised: the intended `>` turns into `>=` (the
server supplies the `=`), and the comparison runs against the string `'5'`, not
the number. On one dataset `where: { "Estimate>": "5" }` returned 35 rows where
the correct `filter: ["Estimate>'5'"]` returned 8. Because it looks like it
works, this is easy to ship and never notice. Operators go in `filter`.

:::

## Response Format

Query responses can arrive in two formats. Always normalize both:

### Nested Array Format

```json
[
  [
    {
      "_oid": "Story:4786",
      "Number": "S-02574",
      "Name": "API Documentation",
      "Status": {
        "_oid": "StoryStatus:2187",
        "Name": "Development"
      },
      "Status.Name": "Development",
      "Scope.Name": "Platform Team",
      "Owners.Name": ["Alice", "Bob"],
      "Owners.ID": [{ "_oid": "Member:1001" }, { "_oid": "Member:1002" }]
    }
  ]
]
```

:::note Scalar values arrive as JSON strings
Numeric and date attributes are returned as strings — `"Estimate": "2"`, not
`"Estimate": 2`. Convert before doing arithmetic or numeric comparisons on
the client.
:::

## Select Projections

### Basic Attributes

Request simple attributes directly:

```json
"select": ["Number", "Name", "Description"]
```

### Nested Attributes

Access related object properties:

```json
"select": ["Status.Name", "Scope.Name", "Owners.Name"]
```

**Best Practice**: Include both the relation object and nested attributes:

```json
"select": ["Status", "Status.Name", "Owners", "Owners.Name"]
```

This handles different response formats from various Agility versions.

### Multi-Value Attributes

Some attributes return arrays:

```json
{
  "Owners.Name": ["Alice", "Bob"],
  "Owners.ID": [{ "_oid": "Member:1001" }, { "_oid": "Member:1002" }]
}
```

## Filtering

### Where Clause (Equality)

Use `where` for exact matches:

```json
{
  "where": {
    "Number": "S-02574",
    "Scope": "Scope:1338"
  }
}
```

### Filter Expressions (Advanced)

Use `filter` for complex conditions:

```json
{
  "filter": [
    "Status.Name='Ready'",
    "Status.Name='Development'",
    "Scope.Name='Platform Team'",
    "AssetState!='Dead'"
  ]
}
```

**Filter Operators**: the full set is `=`, `!=`, `<`, `<=`, `>`, `>=`, `~=`
(LIKE), with `&` and `;` for AND, `|` for OR, `( )` for grouping, and `+`/`-`
prefixes for existence tests. AND and OR have the **same precedence**, so
parenthesise when mixing them. See
[filter expressions](/reference/query-grammar/filter-expressions) for the
complete reference.

**Example**: Multiple conditions

```json
{
  "filter": ["(Status.Name='Ready'|Status.Name='Development')&AssetState!='Dead'"]
}
```

:::caution How multiple `filter` array entries combine is unverified
The parser collects the entries into a list without documenting whether they are
ANDed or ORed, and the examples on this page have historically implied both. Put
the whole condition in a single expression string — using explicit `;`, `|` and
parentheses — rather than relying on how separate array entries are joined.
:::

## Sorting

Sort results by one or more fields:

```json
{
  "sort": [
    "+ChangeDate", // Ascending
    "-CreateDate" // Descending
  ]
}
```

Prefix with `+` for ascending, `-` for descending.

## Pagination

Retrieve large datasets in chunks:

```json
{
  "page": {
    "start": 0, // Starting index (0-based)
    "size": 50 // Number of results per page
  }
}
```

**Example**: Get page 2 (results 50-99)

```json
{
  "page": {
    "start": 50,
    "size": 50
  }
}
```

## Grouping

`group` buckets the result set. The response nests each group's assets under a
`_children` array on a group-header object.

Two forms work:

**A relation name.** Each distinct value of the relation becomes a group:

```json
{ "from": "Story", "select": ["Name"], "group": ["Status"], "page": { "start": 0, "size": 50 } }
```

```json title="Response shape (abridged)"
[
  [
    {
      "_oid": "NULL",
      "Status": { "_oid": "NULL" },
      "_children": [
        { "_oid": "Story:1210", "Status": { "_oid": "NULL" }, "Name": "Rollover pop-up window" }
      ]
    }
  ]
]
```

Assets whose relation is unset land in a group whose `_oid` is the string
`"NULL"` — handle that bucket rather than assuming every group header is a
real asset.

**A grouping subquery.** Nest a `from`/`select` object to shape the group
headers — for example, to carry each group's `Name`:

```json
{
  "from": "Story",
  "select": ["Name"],
  "group": [{ "from": "Status", "select": ["Name"] }],
  "page": { "start": 0, "size": 50 }
}
```

```json title="Response shape (abridged)"
[
  [
    { "_oid": "StoryStatus:133", "Name": "Future", "_children": ["..."] },
    { "_oid": "StoryStatus:1082", "Name": "Done", "_children": ["..."] }
  ]
]
```

A dotted path such as `group: ["Status.Name"]` does **not** work — group by
the relation itself, or by a grouping subquery selecting the attribute you
wanted, not by an attribute reached through the relation.

## Common Queries

### Get Story by Number

```json
{
  "from": "Story",
  "select": ["Number", "Name", "Status.Name"],
  "where": { "Number": "S-02574" }
}
```

### Get All Active Stories in a Scope

```json
{
  "from": "Story",
  "select": ["Number", "Name", "Status.Name", "Owners.Name"],
  "where": { "Scope": "Scope:1338" },
  "filter": ["AssetState='Active'"]
}
```

### Get Stories by Status

```json
{
  "from": "Story",
  "select": ["Number", "Name", "ChangeDate"],
  "filter": ["Status.Name='Ready'", "Status.Name='Development'"],
  "sort": ["+ChangeDate"]
}
```

### Get Recent Defects

```json
{
  "from": "Defect",
  "select": ["Number", "Name", "Priority.Name", "CreateDate"],
  "sort": ["-CreateDate"],
  "page": { "start": 0, "size": 20 }
}
```

## Code Examples

### cURL

```bash
curl "${AGILITY_BASE_URL}/query.v1" \
  -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Story",
    "select": ["Number", "Name", "Status.Name"],
    "where": {"Number": "S-02574"}
  }'
```

### Python

```python
import os
import requests

def query_story(number):
    payload = {
        "from": "Story",
        "select": ["Number", "Name", "Status.Name", "Scope.Name"],
        "where": {"Number": number}
    }

    response = requests.post(
        f"{os.environ['AGILITY_BASE_URL']}/query.v1",
        headers={
            "Authorization": f"Bearer {os.environ['AGILITY_ACCESS_TOKEN']}",
            "Content-Type": "application/json"
        },
        json=payload,
        timeout=30
    )

    response.raise_for_status()
    return response.json()

# Usage
result = query_story("S-02574")
print(result)
```

### TypeScript

```typescript
interface QueryPayload {
  from: string;
  select: string[];
  where?: Record<string, string>;
  filter?: string[];
  sort?: string[];
  page?: { start: number; size: number };
}

async function queryStories(baseUrl: string, token: string, payload: QueryPayload): Promise<any> {
  const response = await fetch(`${baseUrl}/query.v1`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error(`Query failed: ${response.status}`);
  }

  return response.json();
}

// Usage
const result = await queryStories(baseUrl, token, {
  from: 'Story',
  select: ['Number', 'Name', 'Status.Name'],
  where: { Number: 'S-02574' },
});
```

## Error Handling

| Status Code | Meaning                                         | Solution                                        |
| ----------- | ----------------------------------------------- | ----------------------------------------------- |
| 400         | Invalid query syntax or missing required fields | Validate your query structure                   |
| 401         | Authentication failed                           | Check your access token                         |
| 404         | Asset not found                                 | Verify the asset exists and you have permission |
| 429         | Rate limit exceeded                             | Implement exponential backoff                   |
| 500         | Unknown key in the request body — the response body names it: `"Invalid key at node(s): root->…"` | Remove or correct the offending key; this is a request defect, not a server fault, despite the status |
| 500/503     | Server error                                    | Retry with exponential backoff                  |

## Best Practices

1. **Include both object and nested properties** - Handles different response formats
2. **Filter by AssetState** - Exclude deleted assets: `AssetState!='Dead'`
3. **Use pagination** - Don't retrieve thousands of records at once
4. **Request only needed attributes** - Smaller payloads = faster responses
5. **Validate attributes** - Check [Meta API](/apis/meta-v1) before using custom fields
6. **Handle both response formats** - Normalize nested arrays and query objects

## Related Topics

- [Query grammar](/reference/query-grammar) - The attribute-path and filter-expression vocabulary shared with `rest-1.v1`, plus the full comparison of the two surfaces
- [Meta API](/apis/meta-v1) - Discover available attributes
- [REST API](/apis/rest-1) - Create and update assets
- [Payload Patterns](/guides/payload-patterns) - Best practices for payloads
- [Code Examples](/examples/curl) - More query examples
- [Official Query API Documentation](https://docs.digital.ai/agility/docs/developerlibrary/data-api)
- [API Endpoints Overview](https://docs.digital.ai/agility/docs/developerlibrary/endpoints)
