Skip to main content

Query API (query.v1)

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

Request Structure​

Basic Query​

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

Complete Query Options​

{
"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​

FieldRequiredTypeDescription
fromYesstringAsset type to query (Story, Defect, Task, Test, etc.)
selectYesarrayAttributes to retrieve; a nested object is a subquery. sel is an alias for the key
whereNoobjectEquality shorthand only — {"Number": "S-123"}. Not the filter language
filterNostring or arrayFilter expressions — the same language as where on rest-1.v1
withNoobjectFilter context: variable name to a scalar or list
sortNoarraySort order (+Field ascending, -Field descending)
pageNoobjectPagination with start and size — when present, both are required; see below
groupNoarrayGroup the results - see Grouping
findNostringText to search for
findinNoarrayAttributes find searches; defaults to Name and ShortName
asofNostringA moment number or a datetime
deletedNobooleanInclude deleted assets
needTotalNobooleanAccepted 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, 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, 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.

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.

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

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​

[
[
{
"_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" }]
}
]
]
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:

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

Nested Attributes​

Access related object properties:

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

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

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

This handles different response formats from various Agility versions.

Multi-Value Attributes​

Some attributes return arrays:

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

Filtering​

Where Clause (Equality)​

Use where for exact matches:

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

Filter Expressions (Advanced)​

Use filter for complex conditions:

{
"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 for the complete reference.

Example: Multiple conditions

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

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

Prefix with + for ascending, - for descending.

Pagination​

Retrieve large datasets in chunks:

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

Example: Get page 2 (results 50-99)

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

{ "from": "Story", "select": ["Name"], "group": ["Status"], "page": { "start": 0, "size": 50 } }
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:

{
"from": "Story",
"select": ["Name"],
"group": [{ "from": "Status", "select": ["Name"] }],
"page": { "start": 0, "size": 50 }
}
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​

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

Get All Active Stories in a Scope​

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

Get Stories by Status​

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

Get Recent Defects​

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

Code Examples​

cURL​

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​

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​

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 CodeMeaningSolution
400Invalid query syntax or missing required fieldsValidate your query structure
401Authentication failedCheck your access token
404Asset not foundVerify the asset exists and you have permission
429Rate limit exceededImplement exponential backoff
500Unknown 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/503Server errorRetry 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 before using custom fields
  6. Handle both response formats - Normalize nested arrays and query objects