---
title: "Compile one query from the dictionary and answer with its rows"
method: POST
path: "/api/v1/metrics/query"
tags: ["metrics"]
---

# Compile one query from the dictionary and answer with its rows

`POST /api/v1/metrics/query`

## Request

- Base URL: `https://api.app.tightly.io`
- URL: `https://api.app.tightly.io/api/v1/metrics/query`
- Auth: HTTP bearer

Hand this the query the dictionary describes and it answers with rows, the columns, the formats each figure prints in, the window it actually ran over and the query itself, echoed back.

A POST THAT WRITES NOTHING, and it costs `metrics:read` for that reason. The body is a question, not a change: the query is a nested object and a URL is the wrong place for one. Every statement it compiles is a SELECT on the read replica, and every value in the body reaches it as a bound parameter. There is no field for a table, a column or a fragment, and an unknown key is refused by name rather than ignored.

THE FIGURES ARE THE PAGE'S FIGURES. The query is resolved against the same catalog `get_metric_dictionary` publishes and compiled by the same compiler the Sales page reads through, so a warehouse job pulling net sales for a week gets the week the page shows, to the cent.

`time_range` may be absolute or relative. A relative window is echoed back as it was asked and `resolved_time_range` carries the two dates it ran over, because the rows carry buckets and a bucket is an opening date, not a bound. `computed_as_of` appears where a domain serves derived figures and there is a real stamp to report; a projection with none says nothing rather than inventing "now".

Send `{"query": {...}}`, or the query object at the top level. Both are read, because the query travels in a `query` field on every answer here and a caller feeding one back should not have to know which spelling to unwrap it into.

Scope: `metrics:read`.

## Headers

- `Tightly-Version` string
- `Idempotency-Key` string

## Request body

- object
  - `query` object: The query. `domain` and `measures` are required; `dimensions`, `grain`, `time_range`, `filters`, `sort`, `limit` and `comparison` are optional. Every key must be one the dictionary names for that domain.
    - `dimensions` string[]
    - `domain` string
    - `filters` object[]: `{key, operation, value}` triples, on keys the dictionary allows for the domain.
    - `grain` string: day, week, month or quarter, where the domain supports a time range.
    - `limit` integer
    - `measures` string[]
    - `time_range` object: Absolute, `{kind: absolute, start, end}` as two ISO dates, or relative, `{kind: relative, unit, amount}`.

## Response `200`

The rows, and the question that produced them

- object
  - `data` object
    - `columns` string[]
    - `computed_as_of` string: When derived figures in the answer were computed. Absent where there is no stamp.
    - `explanation` string
    - `formats` object: The format each column prints in, straight from the catalog.
    - `query` object: The question as asked, round-trippable into this operation.
    - `resolved_time_range` object, nullable: The two absolute dates the statement ran over, on every query that has a window.
    - `row_count` integer
    - `rows` object[]

## Other responses

- `400`: The query is the caller's to fix. `field` names the control, `key` the term that was wrong and `allowed` what may go there.
- `401`: No usable key: malformed, unknown, revoked, expired or stopped. One refusal covers all five, telling a caller which is which maps the surface for them. A request with no Authorization header at all is refused 400 before any key is looked for.
- `403`: The key may not reach this operation. `scope_missing` when the key does not hold Metrics; `organization_mismatch` when the path names an organisation that is not the key's; `ip_not_allowed` when the caller's address is outside the key's allowlist.
- `429`: The key has spent its allowance for this minute. 600 reads and 120 writes a minute, counted per key, refused calls included. `Retry-After` says how long to wait.
- `502`: The query was valid and the database would not answer it. Ours to fix, not the caller's.
- `503`: The database is briefly out of reach, which is what a failover or a full connection pool looks like from outside. Wait the seconds `Retry-After` names and send the request again. A read is safe to repeat. A write is not, because the connection was lost in the middle of it, so read the object back before sending it a second time. Branch on the status rather than on `code`, which is `POSTGRES_UNAVAILABLE` from one of the two seams that raise this and `SERVICE_UNAVAILABLE` from the other.

---

[API](https://skmtc.dev/tightly/apis/tightly-api.md) · [All operations](https://skmtc.dev/tightly/apis/tightly-api/llms.txt) · [OpenAPI document](https://skmtc.dev/tightly/apis/tightly-api/revisions/199f7eb35afb?raw)
