## Run a read-only SQL query

`POST /v1/infra/servers/:id/db/query`

Runs one read-only SQL query against the application's SQLite database and returns the result columns and rows. Use it for queries with conditions, joins and aggregates that reading the rows of a single table does not support.

Database access conditions and request limits — [When the database is available](/docs/infra/database#when-the-database-is-available) and [Limits](/docs/infra/database#limits). To change data — [Run write SQL](/docs/infra/database/execute).

## Parameters

| Parameter | Type | Required | Description |
|----------|-----|:-----:|----------|
| `id` (path) | string (UUID) | yes | ID of an application with `kind` equal to `GALAXY_APP`. List: [`GET /v1/infra/servers`](/docs/infra/servers/list) |

## Request fields (body)

| Field | Type | Required | Description |
|------|-----|:-----:|----------|
| `sql` | string | yes | One read-only SQL statement, up to 16,384 bytes in UTF-8. Values are bound through positional `?` placeholders |
| `parameters` | array | no | Values for the `?` placeholders in order, up to 256 items. Each is a string up to 16,384 characters, a finite number or `null`. Booleans are not accepted; pass `1` and `0` instead. Default: `[]` |
| `file` | string | no | Database file path relative to `/data`, up to 1024 bytes in UTF-8. File list — `data.files[].file` in [`GET /db`](/docs/infra/database/info). Default — `data.defaultFile` from the same response |

Other body fields return `400 VALIDATION_ERROR`. The body is limited to 64 KiB.

**Pagination.** The endpoint has no cursor or page number: with `data.truncated: true`, rows beyond the limit are dropped. Fetch the rest with the next query: add a stable `ORDER BY` sort and `LIMIT` with `OFFSET` to `sql`, or a condition on the key of the last row received, for example `WHERE id > ?`.

## Examples

### curl — personal key

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/APP_ID/db/query" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT id, name, email FROM customers WHERE id > ? ORDER BY id",
    "parameters": [1]
  }'
```

### curl — OAuth application

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/APP_ID/db/query" \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT id, name, email FROM customers WHERE id > ? ORDER BY id",
    "parameters": [1]
  }'
```

### JavaScript — personal key

```javascript
const res = await fetch(
  `https://vibecode.bitrix24.com/v1/infra/servers/${appId}/db/query`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      sql: 'SELECT id, name, email FROM customers WHERE id > ? ORDER BY id',
      parameters: [1],
    }),
  }
)
const { success, data, error } = await res.json()
if (!success) throw new Error(`${error.code}: ${error.details?.reason ?? error.message}`)
// Rows are positional arrays in data.columns order
const rows = data.rows.map(r => Object.fromEntries(data.columns.map((c, i) => [c, r[i]])))
console.log(rows, data.truncated ? 'result truncated' : '')
```

### JavaScript — OAuth application

```javascript
const res = await fetch(
  `https://vibecode.bitrix24.com/v1/infra/servers/${appId}/db/query`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_APP_KEY',
      'Authorization': 'Bearer USER_SESSION_TOKEN',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      sql: 'SELECT id, name, email FROM customers WHERE id > ? ORDER BY id',
      parameters: [1],
    }),
  }
)
const { success, data, error } = await res.json()
if (!success) throw new Error(error.code)
console.log(data.columns, data.rows)
```

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `success` | boolean | `true` on success |
| `data.file` | string | Path of the file read, relative to `/data` |
| `data.columns` | array | Result column names in order. Duplicate names, for example after a join, remain separate items |
| `data.rows` | array | Result rows, up to 500. Each row is an array of values in `data.columns` order |
| `data.rows[][]` | string \| number \| null \| object | Cell value. Text as a string, numbers as a number, SQL `NULL` as `null`. Values that cannot be passed as a JSON number or string come as an object with a `type` field — see the three rows below |
| `data.rows[][]` with `type: "blob"` | object | Binary data; the `base64` field holds the Base64-encoded content |
| `data.rows[][]` with `type: "integer"` | object | An integer with absolute value greater than 9,007,199,254,740,991; the `value` field holds the number as a string |
| `data.rows[][]` with `type: "float"` | object | Infinity or not-a-number; the `value` field is `Infinity`, `-Infinity` or `NaN` |
| `data.rowCount` | integer | Number of rows in `data.rows` |
| `data.truncated` | boolean | `true` if the result was truncated: more than 500 rows or the response approached 1 MiB. Rows beyond the limit are not returned |
| `data.durationMs` | number | Read time on the host in milliseconds |

## Response example

```json
{
  "success": true,
  "data": {
    "file": "app.db",
    "columns": ["id", "name", "email"],
    "rows": [
      [2, "Customer B", "b@example.com"],
      [3, "Customer C", null]
    ],
    "rowCount": 2,
    "truncated": false,
    "durationMs": 0.75
  }
}
```

## Error response example

422 — the query tries to change data:

```json
{
  "success": false,
  "error": {
    "code": "APP_DB_SQL_ERROR",
    "message": "SQL operation is not permitted",
    "details": {
      "reason": "not_permitted"
    }
  }
}
```

An SQL error response contains neither the query text, nor the values, nor the original SQLite message — only `error.details.reason` names the cause.

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 400 | `VALIDATION_ERROR` | `sql` is missing, an unknown field was passed, `parameters` has more than 256 items or contains an invalid value, `sql` or `file` exceeds the allowed length |
| 400 | `APP_DB_INVALID_FILE` | `file` is an absolute path, contains `.` or `..`, goes through a symbolic link or does not point to an SQLite database |
| 400 | `APP_DB_UNSUPPORTED_PLACEMENT` | The application is not hosted in Galaxy |
| 401 | `MISSING_API_KEY` | The `X-Api-Key` header is missing |
| 401 | `INVALID_API_KEY` | The key is not recognized |
| 402 | `BILLING_FROZEN` | The galaxy host is asleep and the balance is frozen — the host cannot be woken |
| 403 | `INFRA_SCOPE_REQUIRED` | The key lacks the `vibe:infra` scope |
| 403 | `APP_DB_DISABLED` | Database access is not enabled for the portal |
| 403 | `SERVER_WAKE_BLOCKED` | The galaxy host is asleep and waking it is blocked |
| 404 | `NOT_FOUND` | The application does not exist, was deleted or is not accessible to this key |
| 404 | `APP_DB_NOT_FOUND` | There is no `/data` volume or database file — the application has not created a database yet |
| 409 | `APP_DB_WAL_CLOSED` | The database is in WAL mode, but the `-wal` and `-shm` files are missing: a running application has closed the connection. There is no `Retry-After` header |
| 409 | `APP_DB_BUSY` | The host is busy with another operation, did not connect within 10 seconds, or the database is locked by an application write. The response carries a `Retry-After` header |
| 413 | `PAYLOAD_TOO_LARGE` | The request body exceeds 64 KiB, or the request together with service data exceeds 96 KiB |
| 422 | `APP_DB_SQL_ERROR` | `error.details.reason` is `not_permitted`: the statement tries to change data or the schema, control a transaction, attach another database, run `PRAGMA` or load an extension, or call `readfile` or `writefile` |
| 422 | `APP_DB_SQL_ERROR` | `error.details.reason` is `syntax_error`: an SQL syntax error |
| 422 | `APP_DB_SQL_ERROR` | `error.details.reason` is `no_such_table`: a table from the query does not exist in the database |
| 422 | `APP_DB_SQL_ERROR` | `error.details.reason` is `no_such_column`: a column from the query does not exist in the table |
| 422 | `APP_DB_SQL_ERROR` | `error.details.reason` is `type_mismatch`: a value is incompatible with the type |
| 422 | `APP_DB_SQL_ERROR` | `error.details.reason` is `other`: other execution errors, including several statements in one `sql` |
| 422 | `APP_DB_QUERY_TIMEOUT` | The query did not finish within 5 seconds |
| 422 | `APP_DB_CORRUPT` | The database integrity check failed |
| 429 | `RATE_LIMITED` | The read request limit for the portal was exceeded — [Limits](/docs/infra/database#limits). The pause is in the `Retry-After` header |
| 503 | `APP_DB_TOOL_UNAVAILABLE` | The host is not prepared for database reads or is temporarily unavailable. The response carries a `Retry-After` header. Preparation — [Prepare the host](/docs/infra/database/prepare) |
| 503 | `user_self_deletion_pending` | The key owner is deleting their account on this portal |

Full list of common API errors — [Errors](/docs/errors).

## Known specifics

- **The query cannot change the database.** The connection is opened read-only, and in addition to the server-side statement check, SQLite itself rejects writes. That is why, unlike write SQL, `POST /db/query` does not create a protective backup before running.
- **SQL and data are not stored in the audit log.** The log records the operation, the row count and the duration. The `sql` text, `parameters` and result rows are not stored there.

## See also

- [Get the database schema](/docs/infra/database/schema)
- [Get table rows](/docs/infra/database/rows)
- [Run write SQL](/docs/infra/database/execute)
- [Application database](/docs/infra/database)
