Query
The Query API lets you run SQL against your Telemetry data.
POST https://api.telemetry.sh/query
Headers
| Name | Type | Description |
|---|---|---|
| Content-Type | String | application/json |
| Authorization | String | Your API key, either as the raw key or as Bearer <key> |
Body
| Name | Type | Required | Description |
|---|---|---|---|
| query | String | Yes | SQL query to execute. |
| realtime | Boolean | No | Defaults to true. Passed through to the query service. |
| json | Boolean | No | Defaults to true. Passed through to the query service. |
Successful response, 200 OK
{
"status": "success",
"data": [
{
"city": "paris",
"average_price": 42.0
}
],
"key_order": ["city", "average_price"]
}
data contains the result rows. key_order preserves the column order from the query result.
Example usage with cURL
This cURL request queries the average ride price by city in uber_rides:
QUERY=$(cat <<'SQL'
SELECT
city,
AVG(price) AS average_price
FROM
uber_rides
GROUP BY
city
LIMIT
10000;
SQL
)
curl -X POST https://api.telemetry.sh/query \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d "$(jq -n --arg query "$QUERY" '{query: $query, realtime: true, json: true}')"
Using the JavaScript SDK
We recommend using the SDKs for a better developer experience:
import telemetry from "telemetry-sh";
telemetry.init("YOUR_API_KEY");
const results =
await telemetry.query(`
SELECT
city,
AVG(price)
FROM
uber_rides
GROUP BY
city
`);
Async query
Small JSON results are returned inline in result, with data and key_order. Larger JSON results and Parquet exports use download_url. A completed response needs only one of these; inline results do not require a download URL or download URL expiry fields. If download_url_expired is true, start a new query. Treat expiry fields as optional, rather than requiring them to recognize a completed inline result.
This is especially useful for exporting entire tables, running heavy aggregations, or downloading results as JSON or Parquet files.
How it works
POSTyour query to/query/async. The server returns ajob_idandstatus_url.GETthestatus_urlto check progress.- Read results from inline
result, or download the file fromdownload_url.
API reference
Start an async query
POST https://api.telemetry.sh/query/async
Headers
| Name | Type | Description |
|---|---|---|
| Content-Type | String | application/json |
| Authorization | String | Your API key, either as the raw key or as Bearer <key> |
Body
| Name | Type | Required | Description |
|---|---|---|---|
| query | String | Yes | SQL query to execute. |
| realtime | Boolean | No | Defaults to true. Passed through to the query service. |
| json | Boolean | No | Defaults to true. Passed through to the query service. |
| format | String | No | Result format, "json" by default or "parquet". |
Response, 202 Accepted
{
"status": "accepted",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"format": "json",
"status_url": "/query/async/550e8400-e29b-41d4-a716-446655440000"
}
Poll query status
GET https://api.telemetry.sh/query/async/{job_id}
Headers
| Name | Type | Description |
|---|---|---|
| Authorization | String | Your API key, either as the raw key or as Bearer <key> |
Response, 200 OK
Inline JSON result
{
"status": "success",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"query_status": "completed",
"format": "json",
"progress_pct": 100,
"result": {
"data": [{ "events": 7 }],
"key_order": ["events"]
}
}
Result available as a download
{
"status": "success",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"query_status": "completed",
"format": "json",
"progress_pct": 100,
"message": "Query completed",
"created_at": "2025-02-24T12:00:00Z",
"completed_at": "2025-02-24T12:00:05Z",
"download_url": "https://storage.example.com/results/...",
"download_url_expires_in_seconds": 3600
}
When format is "json", the downloaded file contains query metadata plus the standard query result shape under result:
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"format": "json",
"result": {
"data": [
{
"city": "paris",
"average_price": 42.0
}
],
"key_order": ["city", "average_price"]
}
}
The query_status field will be one of:
| Status | Description |
|---|---|
queued |
The query is waiting to be executed |
running |
The query is currently executing |
completed |
Results are ready in result or via download_url. |
failed |
The query failed. Check the error field. |
cancelled |
The query was cancelled. |
Async query with cURL
# 1. Start the async query
QUERY='SELECT * FROM uber_rides'
RESPONSE=$(curl --fail-with-body -sS --max-time 30 https://api.telemetry.sh/query/async \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d "$(jq -n --arg query "$QUERY" '{query: $query, format: "json", realtime: true, json: true}')") || exit 1
STATUS_URL="https://api.telemetry.sh$(echo "$RESPONSE" | jq -r '.status_url')"
# 2. Poll for at most ten minutes
DEADLINE=$((SECONDS + 600))
while [ "$SECONDS" -lt "$DEADLINE" ]; do
STATUS=$(curl --fail-with-body -sS --max-time 30 -H "Authorization: $API_KEY" "$STATUS_URL") || exit 1
QUERY_STATUS=$(echo "$STATUS" | jq -r '.query_status')
if [ "$QUERY_STATUS" = "completed" ]; then
if echo "$STATUS" | jq -e '.download_url_expired == true' >/dev/null; then
echo "Result expired. Start a new query."
exit 1
elif echo "$STATUS" | jq -e '.result | type == "object"' >/dev/null; then
echo "$STATUS" | jq '{result: .result}' > results.json
else
DOWNLOAD_URL=$(echo "$STATUS" | jq -r '.download_url // empty')
if [ -z "$DOWNLOAD_URL" ]; then
echo "Completed response has neither a result nor an active download URL."
exit 1
fi
curl --fail-with-body -sS --max-time 300 -o results.json "$DOWNLOAD_URL" || exit 1
fi
# Both branches have the same result shape, including an empty data array.
jq '.result' results.json
exit 0
elif [ "$QUERY_STATUS" = "failed" ] || [ "$QUERY_STATUS" = "cancelled" ]; then
echo "Query $QUERY_STATUS: $(echo "$STATUS" | jq -r '.error // .message')"
exit 1
elif [ "$QUERY_STATUS" != "queued" ] && [ "$QUERY_STATUS" != "running" ]; then
echo "Unexpected query status: $QUERY_STATUS"
exit 1
fi
sleep 5
done
echo "Query polling timed out."
exit 1
Example: exporting an entire table
Use the async query API for full table exports. It has no row limit and returns a single downloadable file.
# Start the export as Parquet
QUERY='SELECT * FROM uber_rides'
RESPONSE=$(curl -s -X POST https://api.telemetry.sh/query/async \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d "$(jq -n --arg query "$QUERY" '{query: $query, format: "parquet", realtime: true, json: true}')")
STATUS_URL="https://api.telemetry.sh$(echo "$RESPONSE" | jq -r '.status_url')"
# Poll until complete
while true; do
STATUS=$(curl -s -H "Authorization: $API_KEY" "$STATUS_URL")
QUERY_STATUS=$(echo "$STATUS" | jq -r '.query_status')
if [ "$QUERY_STATUS" = "completed" ]; then
DOWNLOAD_URL=$(echo "$STATUS" | jq -r '.download_url // empty')
if [ -z "$DOWNLOAD_URL" ]; then
echo "Export completed, but no active download URL is available."
exit 1
fi
curl -o uber_rides_export.parquet "$DOWNLOAD_URL"
echo "Export complete: uber_rides_export.parquet"
break
elif [ "$QUERY_STATUS" = "failed" ]; then
echo "Export failed: $(echo "$STATUS" | jq -r '.error // .message')"
exit 1
fi
sleep 5
done
SDK boundary
The current JavaScript SDK's query method calls the interactive /query
endpoint. It does not start /query/async, poll status_url, enforce an
asynchronous-job timeout, or download the finished artifact.
Use the HTTP start, poll, and download flow above for large JSON or Parquet
exports. An application wrapper may encapsulate those three operations, but it
should still enforce a total polling deadline, stop on failed, and treat an
expired download_url as a new export rather than retrying the old download
indefinitely.
Common errors
400 Bad Requestif the JSON body is invalid400 Bad Requestifqueryis missing or empty401 Unauthorizedif the API key is missing or invalid402 Payment Requiredif the account is blocked by a paywall check429 Too Many Requestsif the API key exceeds the gateway rate limit