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 —
whereis equality shorthand only;filtercarries the full expression language. They are named the opposite way round fromrest-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.NameandOwners.Name, and use a nested object as a subquery. - Sorting, grouping and paging —
pagetakes a named mapping here ({"start": 0, "size": 50}), unlikerest-1.v1's positionalpage=<size>,<start>. - Multiple independent queries in a single request, returning multiple result sets.
page — there is no default limitOmitting 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
| 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 — the same language as where on rest-1.v1 |
with | No | object | 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 |
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, whose resultMetaData honors it |
The parser accepts exactly these keys — update and execute are silently
stripped, and any other unrecognised key is rejected.
as aliases parse but do nothingThe 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.
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.
IsDeleted filter hack in existing codeOlder 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.
where means something different here than on rest-1.v1On 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.
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" }]
}
]
]
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'"]
}
filter array entries combine is unverifiedThe 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 } }
[
[
{
"_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 }
}
[
[
{ "_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 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
- Include both object and nested properties - Handles different response formats
- Filter by AssetState - Exclude deleted assets:
AssetState!='Dead' - Use pagination - Don't retrieve thousands of records at once
- Request only needed attributes - Smaller payloads = faster responses
- Validate attributes - Check Meta API before using custom fields
- Handle both response formats - Normalize nested arrays and query objects
Related Topics
- Query grammar - The attribute-path and filter-expression vocabulary shared with
rest-1.v1, plus the full comparison of the two surfaces - Meta API - Discover available attributes
- REST API - Create and update assets
- Payload Patterns - Best practices for payloads
- Code Examples - More query examples
- Official Query API Documentation
- API Endpoints Overview