sql over http

one statement, one POST, rows back as json. no driver, no pool, no connection to hold open — which is what makes it work from a serverless function or an edge worker that lives for forty milliseconds.

the endpoint

POST /api/v1/sql is the whole interface. It is POST only — there is no GET and no OPTIONS handler — and it authenticates with an API key sent as a bearer token. The scheme is matched without regard to case, so bearer and Bearer both work.

bash

curl -sS https://briven.tech/api/v1/sql \
  -H "Authorization: Bearer $BRIVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"select $1::int as x","params":[1],"database":"notes"}'

response

{
  "command": "SELECT",
  "rowCount": 1,
  "rows": [{ "x": 1 }],
  "fields": [
    { "name": "x", "dataTypeID": 23, "format": "text" }
  ]
}

you cannot call this from a browser

There are no CORS headers on this route, deliberately. A browser therefore refuses the cross-origin request, and that is the point: the only way to call this endpoint is with an API key, and an API key that reaches a browser is a key that has been given away. Call it from your server, your serverless function or your worker.

the request body

json

{
  "query": "select $1::int as x",
  "params": [1],
  "database": "notes"
}
  • queryRequired, a string. One statement. Placeholders are $1, $2 and so on.
  • paramsOptional, defaults to an empty array. Bound through the extended query protocol, which means the values are sent to Postgres separately from the statement and are never interpolated into it. This is what makes the endpoint safe to hand user input to.
  • databaseOptional, 1 to 200 characters. Accepts either the database id or the friendly name you gave it. This field is briven’s own addition to the protocol — a key can be scoped to your whole organisation, and then it needs to be told which database to run against.

Batched transactions are not supported. A body containing a queries array is refused with 400 batch_not_supported rather than being silently run one statement at a time — a half-applied batch is a worse outcome than a refusal you can read.

the optional headers

Three headers change the shape of what comes back. Each boolean one must be the literal string true — anything else, including 1, is treated as not set.

  • Neon-Raw-Text-Output: trueValues come back exactly as Postgres wrote them, unparsed. Useful when you would rather do your own decoding than have numbers and dates guessed at.
  • Neon-Array-Mode: trueEach row is an array of values in column order rather than an object keyed by column name. The response then carries rowAsArray: true so a client can tell.
  • Neon-Connection-String: <url>Only the database name in the URL path is read from this. Nothing else in it is used, and the header is never logged and never stored. It exists so a client that already holds a connection string does not have to be taught a new field.

the neon client, unmodified

This endpoint speaks Neon’s HTTP protocol, so a stock @neondatabase/serverless client works against it without a patch or a fork. Point the client at https://briven.tech/api/v1/sql, give it your briven API key as the bearer token, and the three headers above are the ones it will set for you.

We do not restate that client’s options here, because they are its documentation and not ours, and a copy of somebody else’s API reference is a copy that goes stale. What briven guarantees is the wire format on this page.

The practical consequence: if you already wrote against Neon over HTTP, the migration is a URL and a key. If you would rather hold a real Postgres connection, connecting covers psql, Prisma and Drizzle instead.

what comes back

response

{
  "command": "SELECT",
  "rowCount": 1,
  "rows": [[1]],
  "fields": [
    {
      "name": "x",
      "tableID": 0,
      "columnID": 0,
      "dataTypeID": 23,
      "dataTypeSize": 4,
      "dataTypeModifier": -1,
      "format": "text"
    }
  ],
  "rowAsArray": true
}
  • commandThe statement tag Postgres returned — SELECT, INSERT and so on.
  • rowCountAlways a number. It is never null, so you do not have to guard it.
  • rowsObjects keyed by column name, or arrays of values if you asked for array mode.
  • fieldsOne entry per column, carrying name, tableID, columnID, dataTypeID, dataTypeSize, dataTypeModifier and format. format is always “text”.
  • rowAsArrayTrue when you sent Neon-Array-Mode: true, so a client can decode rows without having to remember what it asked for.

There is no oid field and no timing field on this response. If you have seen either in another platform’s documentation, it is not here — do not write code that reads them.

when it goes wrong

Every failure, from a missing key to a syntax error, comes back in one shape. There is no second envelope to handle and no HTML error page to accidentally parse.

response

{
  "error": "not_a_read_statement",
  "message": "This API key is read-only and the statement is not a read."
}

Some codes add fields alongside those two — sql_error carries whatever Postgres told us, and rate_limited comes with a Retry-After header.

  • 401 missing_api_keyNo Authorization header was sent at all.
  • 401 invalid_api_keyThe key is malformed, unknown, revoked or expired. All four give this one answer on purpose, so that a key cannot be probed by watching which refusal comes back.
  • 429 rate_limited300 requests in 60 seconds, per key. A Retry-After header comes with it, in whole seconds, never below 1.
  • 413 request_too_largeThe request body was over 4,000,000 bytes.
  • 400 malformed_bodyThe body was not JSON, or query was missing or was not a string.
  • 400 batch_not_supportedThe body contained a queries array. Batched transactions are not supported here — send one statement.
  • 413 query_too_largeThe query string was over 100,000 characters.
  • 413 too_many_parametersMore than 100 params were sent.
  • 413 parameter_too_largeA single string parameter was over 1,000,000 characters.
  • 400 database_not_namedThe key is organisation-wide, so it does not imply a database, and the request did not name one.
  • 403 database_out_of_scopeThe key is not allowed to reach the database it named.
  • 409 database_not_readyThe database exists but is not yet accepting statements.
  • 403 not_a_read_statementA read-only key sent something that is not a read. See below.
  • 400 sql_errorPostgres refused the statement. Carries severity, code, detail, hint, position, where, schema, table, column, dataType and constraint when Postgres supplied them.
  • 413 too_many_rowsThe statement produced more than 10,000 rows. Add a limit, or use a real connection.
  • 503 database_unavailableThe database could not be reached inside the connect timeout.
  • 500 internal_errorOurs. Please report it.

the limits

These are checked before your statement reaches Postgres, so hitting one costs you nothing but the round trip.

  • request body4,000,000 bytes.
  • query100,000 characters.
  • params100 of them.
  • one string param1,000,000 characters.
  • rows returned10,000.
  • statement timeout30,000 ms, set on the transaction itself so Postgres enforces it.
  • connect timeout10,000 ms.
  • rate limit300 requests per 60 seconds, per API key.

how your statement is actually run

Each request takes one fresh pooled connection, and your statement runs alone inside BEGIN COMMIT with SET LOCAL statement_timeout = 30000 applied to that transaction. Wrapping a single statement in a transaction sounds redundant, and it is not: it is what gives the timeout and the read-only mode something to be scoped to, so neither can leak onto the next request that borrows the same connection.

When the key is read-only, SET TRANSACTION READ ONLY is added as well, so Postgres itself refuses a write even if something got past the allowlist below. And the actor is recorded for the change log, which is what makes a row written over HTTP show up in history and undo like any other.

what a read-only key may send

A read-only key is checked before the statement leaves briven. The rule is deliberately narrow rather than clever:

  • allowedStatements beginning with select or with. That is the entire allowlist.
  • refusedinsert, update, delete or merge appearing anywhere outside a string literal or a comment. Scanning the whole statement rather than just its first word is what catches a data-modifying CTE — a with … as (delete from …) that begins with an innocent word.
  • not allowed eitherTABLE, VALUES, SHOW and EXPLAIN. They read nothing dangerous, but every keyword added to an allowlist is a keyword whose edge cases have to be reasoned about, and these were not worth it.

A known false refusal: SELECT … FOR UPDATE is rejected by a read-only key even though it reads. It contains the word UPDATE, and the scanner would rather refuse a legitimate lock than let a disguised write through. Use a full-access key for that statement.

where to go next

To create the database you are querying, or to branch it from a script, use the management api — or the cli, which is the same thing without pasting keys about. For a throwaway copy of your data to run destructive statements against, read branching. And if this endpoint is too narrow for what you are doing, take a connection string instead: connecting.