Data API
The Data API is how other systems push data into Querri and read it back out. Use it to send rows from another app as they happen, run a nightly sync from a system of record, feed a read-only reporting tool, or build your own pipeline in any language.
This page covers everything a Data API integration needs. There are three ways to call it; pick whichever fits your stack.
Three ways to call the API
Section titled “Three ways to call the API”| Path | When to use it | What you get |
|---|---|---|
| Python SDK | Anything written in Python | Typed sync and async clients, pagination, retries, environment-variable auth |
| querri CLI | Scheduled jobs, CI scripts, one-off commands, shell pipelines | One command per operation, JSON output, browser sign-in or environment-variable auth |
| Raw HTTP | Any other language, or a no-code tool’s webhook step | Full control, no extra dependency |
The SDK and CLI ship in the same querri package on PyPI. If you’re in Python, use the SDK; every example below has an SDK form. If you’re not, the curl examples are what to translate.
Quick start
Section titled “Quick start”Python SDK
Section titled “Python SDK”pip install querriexport QUERRI_API_KEY=qk_your_key_hereexport QUERRI_ORG_ID=your_org_idfrom querri import Querri
client = Querri() # reads QUERRI_API_KEY and QUERRI_ORG_ID
source = client.sources.create_data_source( name="Web Leads", rows=[ {"name": "Alice", "email": "alice@example.com", "score": 85}, {"name": "Bob", "email": "bob@example.com", "score": 92}, ],)print(source.id, source.row_count)For async code, use AsyncQuerri and await the calls.
pip install "querri[cli]"querri auth login # or set QUERRI_API_KEY and QUERRI_ORG_ID
echo '[{"name":"Alice","email":"a@example.com"}]' \ | querri source new --name "Web Leads"
querri source listquerri source data <source_id> --page-size 100Raw HTTP
Section titled “Raw HTTP”curl -X POST https://app.querri.com/api/v1/sources \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id" \ -H "Content-Type: application/json" \ -d '{ "name": "Web Leads", "rows": [{"name": "Alice", "email": "alice@example.com", "score": 85}] }'API basics
Section titled “API basics”Base URL
Section titled “Base URL”All public API endpoints live under:
https://app.querri.com/api/v1Authentication
Section titled “Authentication”For server-to-server integrations, use an API key and send two headers on every request:
Authorization: Bearer qk_your_secret_hereX-Tenant-ID: your_org_idContent-Type: application/json # for POST and PUTThe SDK and CLI send both when QUERRI_API_KEY and QUERRI_ORG_ID are set, or when you pass Querri(api_key=..., org_id=...).
The API also accepts a signed-in user’s token, an embed session, and browser cookies. See Authentication for all four, and API Keys for creating keys.
Scopes
Section titled “Scopes”| Scope | What it allows |
|---|---|
data:read | List sources, get a schema, read rows, run SQL, ask a question |
data:write | Create, append to, replace and delete sources |
A key with only data:read can’t change sources. A key with only data:write can’t read data back, so give it both if your integration checks what it wrote. Listing and getting sources also accept admin:sources:read, and deleting accepts admin:sources:write.
Other parts of the API have their own scopes. See the API Reference.
Rate limits
Section titled “Rate limits”60 requests a minute per key by default. You can set a key’s limit from 1 to 10,000 when you create it. Past the limit, the API returns 429 with a Retry-After header.
Endpoint reference
Section titled “Endpoint reference”All paths are relative to https://app.querri.com/api/v1.
| Operation | Method | Path | Scope |
|---|---|---|---|
| List sources | GET | /sources | data:read |
| Get a source and its schema | GET | /sources/{id} | data:read |
| Read rows | GET | /sources/{id}/data | data:read |
| Run SQL | POST | /sources/{id}/query | data:read |
| Ask a question | POST | /sources/{id}/ask | data:read |
| Create a source | POST | /sources | data:write |
| Append rows | POST | /sources/{id}/rows | data:write |
| Replace all rows | PUT | /sources/{id}/data | data:write |
| Delete a source | DELETE | /sources/{id} | data:write |
Source IDs are UUIDs.
Reading data
Section titled “Reading data”List sources
Section titled “List sources”# Python SDKfor s in client.sources.list(): print(s["id"], s["name"], s.get("row_count"))# CLIquerri source listquerri --json source list # machine-readable# HTTPcurl "https://app.querri.com/api/v1/sources?limit=100" \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id"The response is one page of sources. Each has id, name, service, connector_id, columns, row_count, materialized, status, workspace_id, access_controlled and updated_at.
{ "data": [ { "id": "8f14e45f-ceea-4ba1-9f3c-5d2b8a0c1e77", "name": "Web Leads", "service": "api", "columns": ["name", "email", "score"], "row_count": 1000, "materialized": true, "status": "ready", "access_controlled": false } ], "has_more": false, "next_cursor": null}Pages hold up to 100 sources (limit, default 20). Pass next_cursor back as after for the next page. The SDK’s list() does this for you when you loop over it.
Only a materialized source can be queried. An Excel upload, for example, isn’t materialized until it’s analyzed.
Get a source
Section titled “Get a source”source = client.sources.get(source_id)querri source describe <source_id>curl https://app.querri.com/api/v1/sources/{source_id} \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id"Returns columns, column_types, per-column details such as counts and example values, row_count, materialized, and status, progress and error_message, so a poller can tell a source that’s still processing from one that failed.
Read rows
Section titled “Read rows”page = client.sources.source_data(source_id, page=1, page_size=1000)print(page.total_rows, len(page.data))querri source data <source_id> --page 1 --page-size 1000curl "https://app.querri.com/api/v1/sources/{source_id}/data?page=1&page_size=100" \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id"Response:
{ "data": [ {"name": "Alice", "email": "alice@example.com", "score": 85}, {"name": "Bob", "email": "bob@example.com", "score": 92} ], "total_rows": 2, "page": 1, "page_size": 100}page_size goes up to 10,000.
Run a SQL query
Section titled “Run a SQL query”The source ID goes in the URL path. The body holds the query and paging.
result = client.sources.query( sql="SELECT name, score FROM data WHERE score > 80", source_id=source_id, page=1, page_size=100,)print(result.total_rows, result.data)querri source query --source-id <source_id> --sql "SELECT name, score FROM data WHERE score > 80"curl -X POST https://app.querri.com/api/v1/sources/{source_id}/query \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id" \ -H "Content-Type: application/json" \ -d '{ "sql": "SELECT name, score FROM data WHERE score > 80", "page": 1, "page_size": 100 }'Queries run in DuckDB against a view named data. Only SELECT statements are allowed, up to 10,000 characters. Row-level security applies (see Who can read what).
The response adds ordering to data, total_rows, page and page_size. Each page reruns the query, so rows are sorted by every column you select to keep page boundaries stable.
Ask a question
Section titled “Ask a question”Querri writes a SELECT for your question, checks it, and runs it the same way as a SQL query.
answer = client.sources.ask(source_id, question="Which five leads have the highest score?")print(answer["generated_sql"])print(answer["data"])querri source ask <source_id> "Which five leads have the highest score?"curl -X POST https://app.querri.com/api/v1/sources/{source_id}/ask \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id" \ -H "Content-Type: application/json" \ -d '{"question": "Which five leads have the highest score?"}'The body takes question (up to 2,000 characters), plus optional page and page_size. The response has the rows, generated_sql and your question. If the generated SQL is rejected, the error is invalid_generated_sql and includes the SQL.
Writing data
Section titled “Writing data”Create a source
Section titled “Create a source”The SDK has two create methods on client.sources. Use create_data_source for rows of JSON. The API has no endpoint for connector-backed sources, so create isn’t useful here.
source = client.sources.create_data_source( name="Web Leads", rows=[ {"name": "Alice", "email": "alice@example.com", "score": 85}, {"name": "Bob", "email": "bob@example.com", "score": 92}, ],)# source.id, source.name, source.columns, source.row_count, source.updated_atecho '[{"name":"Alice","email":"a@example.com","score":85}]' \ | querri source new --name "Web Leads"curl -X POST https://app.querri.com/api/v1/sources \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id" \ -H "Content-Type: application/json" \ -d '{ "name": "Web Leads", "rows": [ {"name": "Alice", "email": "alice@example.com", "score": 85}, {"name": "Bob", "email": "bob@example.com", "score": 92} ] }'Response (201 Created):
{ "id": "8f14e45f-ceea-4ba1-9f3c-5d2b8a0c1e77", "name": "Web Leads", "columns": ["name", "email", "score"], "row_count": 2, "updated_at": "2026-03-08T15:30:00.000000"}Querri works out each column’s type from the values you send. The new source belongs to the key’s bound user, or to the person who created the key, and lands in that person’s private workspace.
Append rows
Section titled “Append rows”Add rows to an existing source. Columns are matched by name: new columns are added, and missing ones are filled with nulls. Use this when rows arrive over time.
result = client.sources.append_rows( source_id, rows=[ {"name": "Charlie", "email": "charlie@example.com", "score": 78}, {"name": "Diana", "email": "diana@example.com", "score": 95}, ],)curl -X POST https://app.querri.com/api/v1/sources/{source_id}/rows \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id" \ -H "Content-Type: application/json" \ -d '{ "rows": [ {"name": "Charlie", "email": "charlie@example.com", "score": 78} ] }'The source must already have data. If it doesn’t, the API returns no_data; use replace or create instead.
Replace all data
Section titled “Replace all data”Swap a source’s contents for a new dataset. Use this for full nightly syncs.
result = client.sources.replace_data(source_id, rows=fresh_export)curl -X PUT https://app.querri.com/api/v1/sources/{source_id}/data \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id" \ -H "Content-Type: application/json" \ -d '{ "rows": [ {"name": "Eve", "email": "eve@example.com", "score": 100} ] }'Delete a source
Section titled “Delete a source”client.sources.delete(source_id)querri source delete <source_id>curl -X DELETE https://app.querri.com/api/v1/sources/{source_id} \ -H "Authorization: Bearer qk_your_key_here" \ -H "X-Tenant-ID: your_org_id"The response is:
{ "id": "8f14e45f-ceea-4ba1-9f3c-5d2b8a0c1e77", "deleted": true }A source created through the API or from an upload is removed along with its data. A source that came from a connector is marked deleted and its data files are removed, so the connector still knows what it synced before.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Rows per write request | 100,000 |
| Request body | 50 MB |
| Rows per page (reads) | 10,000 |
| SQL length | 10,000 characters |
| Question length | 2,000 characters |
| Rate limit | 60 a minute per key by default (up to 10,000) |
Who can read what
Section titled “Who can read what”Two checks apply to every request.
Item access. The key acts as its bound user, or, without one, as the person who created it. That person must be able to see the source. Reading needs view access, appending and replacing need edit access, and deleting needs owner access. Otherwise the API returns 403 with access_denied or insufficient_permission.
Row-level security applies to reads:
- A key with a bound user is filtered by that user’s access policies.
- A key without one is filtered by access policies that list the key itself.
- With no applicable policy, the key reads every row, except in sources marked access-controlled (
access_controlled: true), which return no rows until a policy matches.
The access_policy_ids field on a key has no effect, so don’t use it to limit rows.
Writes don’t apply row filters. The key needs edit or owner access to the source, as above.
Source scope
Section titled “Source scope”A key can be limited to a list of sources, but only when it’s created through POST /keys with a source scope in explicit mode:
{ "mode": "explicit", "source_ids": ["8f14e45f-ceea-4ba1-9f3c-5d2b8a0c1e77"] }That key lists only those sources and gets 403 with source_not_in_scope for any other. New sources it creates aren’t added to its list, so integrations that create sources need a key without a source scope.
A Source Scope chosen in Settings → API Keys doesn’t restrict the key. It can still read every source its creator can.
Setting up API keys for integrations
Section titled “Setting up API keys for integrations”An admin creates keys at Settings → API Keys (/settings/api) with Create API Key. Choose Custom to pick data:read and data:write.
Suggested configurations
Section titled “Suggested configurations”| Integration | Name | Scopes | Notes |
|---|---|---|---|
| Create and append from another app | CRM Sync | data:read, data:write | Read lets it check what it wrote |
| Append only | Lead Capture | data:write | Fine if it never reads back |
| Nightly batch sync | Nightly Sync Bot | data:read, data:write | Read to check, write to replace |
| Reporting tool | Reporting Read-Only | data:read | To limit sources, create the key with POST /keys and an explicit source scope |
Key properties
Section titled “Key properties”- Bound user: the identity reads are filtered as. Set it with
bound_user_idthrough the API. - Source scope: enforced only when set through
POST /keysin explicit mode. - Expiry: 90 days by default, a year at most. Replace keys before they expire.
- Rate limit: 60 a minute by default; raise it for busy integrations.
Security best practices
Section titled “Security best practices”- Use the fewest scopes that work. An integration that only sends rows needs
data:write, notdata:read. - One key per integration. Don’t reuse a sync key in a reporting tool.
- Add an IP allowlist for server-to-server keys when the addresses are stable.
- Store the secret in a vault. The
qk_secret is shown once, when the key is created.
Integration patterns
Section titled “Integration patterns”Python (SDK, recommended)
Section titled “Python (SDK, recommended)”The full lifecycle: create, append, read, replace, delete.
from querri import Querri
client = Querri() # QUERRI_API_KEY and QUERRI_ORG_ID from the environment
# 1. Createsource = client.sources.create_data_source( name="CRM Contacts", rows=[{"name": "Alice", "email": "alice@corp.com", "deal_stage": "qualified"}],)
# 2. Appendclient.sources.append_rows(source.id, rows=[ {"name": "Bob", "email": "bob@corp.com", "deal_stage": "proposal"}, {"name": "Carol", "email": "carol@corp.com", "deal_stage": "closed_won"},])
# 3. Read backpage = client.sources.source_data(source.id, page=1, page_size=100)print(f"{page.total_rows} contacts loaded")
# 4. Nightly: replace with the full exportclient.sources.replace_data(source.id, rows=fresh_crm_export)
# 5. Remove it when you're doneclient.sources.delete(source.id)For asyncio backends, use AsyncQuerri and await each call.
Scheduled job or CI (CLI)
Section titled “Scheduled job or CI (CLI)”The CLI is the shortest path for scheduled jobs and operator scripts:
# 1. Authenticate once (browser sign-in, saved to ~/.querri/tokens.json)querri auth login
# Or, in CI, set environment variablesexport QUERRI_API_KEY=qk_...export QUERRI_ORG_ID=your_org_id
# 2. Load a JSON export as a new sourcecat fresh_export.json \ | querri source new --name "Nightly CRM Snapshot $(date +%F)"
# 3. Or query an existing sourcequerri --json source query --source-id <source_id> \ --sql "SELECT region, COUNT(*) FROM data GROUP BY region"The CLI’s source commands are list, get, describe, data, query, ask, new, update, delete, sync and connectors. For appending or replacing rows, use the SDK or HTTP.
Webhook from another app: append on each new record
Section titled “Webhook from another app: append on each new record”A common pattern: when a record appears in another app, append it to a Querri source. Any tool that can send an HTTP request works.
Once: create the source with the SDK or curl, and save the returned id.
For each record, send:
- Method:
POST - URL:
https://app.querri.com/api/v1/sources/{source_id}/rows - Headers:
Authorization: Bearer qk_your_key_hereX-Tenant-ID: your_org_idContent-Type: application/json
- Body:
{"rows": [{"name": "{{name}}","email": "{{email}}","company": "{{company}}","created_at": "{{created_date}}"}]}
Test with one record before you turn it on.
Node.js (raw HTTP)
Section titled “Node.js (raw HTTP)”const BASE = "https://app.querri.com/api/v1";const headers = { Authorization: `Bearer ${process.env.QUERRI_API_KEY}`, "X-Tenant-ID": process.env.QUERRI_ORG_ID, "Content-Type": "application/json",};
const create = await fetch(`${BASE}/sources`, { method: "POST", headers, body: JSON.stringify({ name: "CRM Contacts", rows: [{ name: "Alice", email: "alice@corp.com" }], }),});const { id } = await create.json();
await fetch(`${BASE}/sources/${id}/rows`, { method: "POST", headers, body: JSON.stringify({ rows: [{ name: "Bob", email: "bob@corp.com" }], }),});
const read = await fetch(`${BASE}/sources/${id}/data?page=1&page_size=100`, { headers });const { data, total_rows } = await read.json();The same requests work from Go, Ruby, Java or anything else with an HTTP client.
Read-only reporting
Section titled “Read-only reporting”Create a key with only data:read, then walk every source and page through its rows:
from querri import Querri
client = Querri() # a read-only key
for s in client.sources.list(): if not s.get("materialized"): continue rows = [] page_number = 1 while True: page = client.sources.source_data(s["id"], page=page_number, page_size=1000) rows.extend(page.data) if not page.data or len(rows) >= (page.total_rows or 0): break page_number += 1 print(f"{s['name']}: loaded {len(rows)} rows")Error codes
Section titled “Error codes”Errors come back as {"detail": {"error": {"type", "code", "message"}}}.
| Code | HTTP | When it happens |
|---|---|---|
source_not_found | 404 | No source with that ID |
no_data | 400 or 404 | The source has no data (append needs existing data) |
access_denied | 403 | The key’s identity can’t see the source |
insufficient_permission | 403 | The identity can see the source but needs edit or owner access |
source_not_in_scope | 403 | The key’s explicit source scope doesn’t include this source |
insufficient_scope | 403 | The key is missing the scope the endpoint needs |
too_many_rows | 400 | More than 100,000 rows in one request |
empty_data | 400 | The rows have no columns |
payload_too_large | 413 | The body is over 50 MB |
invalid_sql | 400 | The SQL isn’t a single allowed SELECT |
query_failed | 400 | The SQL was allowed but failed to run |
invalid_generated_sql | 400 | For a question, the SQL Querri wrote was rejected |
llm_error | 500 | For a question, Querri couldn’t write the SQL |
The Python SDK raises these as typed exceptions: NotFoundError, PermissionError, ValidationError, RateLimitError and so on. See Error handling.
Next steps
Section titled “Next steps”- Python SDK:
pip install querri, with sync and async clients. - Querri CLI:
pip install "querri[cli]", thenquerri auth login. - Authentication: API keys, tokens, embed sessions and cookies.
- API Keys: creating and revoking keys.
- API Reference: every public endpoint, including projects, dashboards, files and policies.