Database REST API
Query your PostgreSQL databases directly from your application using a REST API.
Query your PostgreSQL databases directly from your application using a REST API.
Overview
The Database REST API lets you perform CRUD operations on your database tables using HTTP requests.
| Feature | Description |
|---|---|
| Frontend applications | Query databases directly from browser/mobile |
| Quick prototyping | No SQL knowledge required |
| Row-level security | Your RLS policies apply to user requests |
| Type-safe | Structured queries with validation |
Authentication: Requires an auth user access token (obtained via signup/signin)
Endpoint: POST /databases/{databaseName}/query/{operation}
To run the same operations against a branch instead of the database itself, put
the branch in the path: POST /databases/{databaseName}/branches/{branchName}/query/{operation}. Everything on
this page applies unchanged. See Branching.
Getting Started
1. Sign Up a User
const response = await fetch('https://api.yourapp.com/auth/signup', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ANON_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'securePassword123',
}),
});
const { access_token } = await response.json();
// Save this access_token for database queries2. Query Your Database
const response = await fetch('https://api.yourapp.com/databases/notes_db/query/select', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`, // ← Your access token (proves your identity!)
'Content-Type': 'application/json',
},
body: JSON.stringify({
table: 'posts',
select: ['id', 'title', 'content'],
filters: [{ column: 'status', operator: 'eq', value: 'published' }],
order: [{ column: 'created_at', ascending: false }],
limit: 10,
}),
});
const { data } = await response.json();
console.log('Posts:', data);
// Data is automatically filtered to your posts only
// The access_token identifies you to the database
// auth.uid() returns your user_id
// RLS filters the resultsOperations
SELECT - Query Data
Endpoint: POST /databases/{databaseName}/query/select
Request Body:
{
"table": "posts",
"select": ["id", "title", "content"],
"filters": [
{ "column": "status", "operator": "eq", "value": "published" },
{ "column": "views", "operator": "gt", "value": 100 }
],
"order": [{ "column": "created_at", "ascending": false }],
"limit": 20,
"offset": 0
}Response:
{
"data": [
{
"id": "uuid",
"title": "My Post",
"content": "Post content here",
"created_at": "2026-01-13T10:00:00Z"
}
],
"count": 1
}Parameters:
table(required): Table nameselect(optional): Columns to return (omit for*)filters(optional): WHERE conditionsorder(optional): ORDER BY clauseslimit(optional): Maximum rows to returnoffset(optional): Number of rows to skip (pagination)
INSERT - Create Data
Endpoint: POST /databases/{databaseName}/query/insert
Request Body:
{
"table": "posts",
"values": {
"title": "My New Post",
"content": "This is the content",
"status": "draft"
}
}Response:
{
"data": [
{
"id": "uuid",
"title": "My New Post",
"content": "This is the content",
"status": "draft",
"user_id": "user-uuid",
"created_at": "2026-01-13T10:00:00Z"
}
],
"count": 1
}Note: If your table has a trigger that auto-sets user_id using auth.uid(), it will be set to the authenticated user automatically!
JSON objects and arrays in values are stored as JSON, so send them to json or jsonb columns. For a Postgres array column such as text[], send an array literal string like "{support,urgent}". Update values follow the same rule.
UPDATE - Modify Data
Endpoint: POST /databases/{databaseName}/query/update
Request Body:
{
"table": "posts",
"values": {
"title": "Updated Title",
"status": "published"
},
"filters": [{ "column": "id", "operator": "eq", "value": "post-uuid" }]
}Response:
{
"data": [
{
"id": "post-uuid",
"title": "Updated Title",
"status": "published",
"updated_at": "2026-01-13T10:05:00Z"
}
],
"count": 1
}Security: Row-Level Security ensures you can only update your own data!
DELETE - Remove Data
Endpoint: POST /databases/{databaseName}/query/delete
Request Body:
{
"table": "posts",
"filters": [{ "column": "id", "operator": "eq", "value": "post-uuid" }]
}Response:
{
"data": [
{
"id": "post-uuid",
"title": "Deleted Post"
}
],
"count": 1
}Safety: UPDATE and DELETE require at least one filter to prevent accidental mass changes.
Security: Row-Level Security ensures you can only delete your own data!
Filter Operators
| Operator | SQL Equivalent | Example |
|---|---|---|
eq | = | { "column": "status", "operator": "eq", "value": "published" } |
neq | <> | { "column": "status", "operator": "neq", "value": "draft" } |
gt | > | { "column": "views", "operator": "gt", "value": 100 } |
gte | >= | { "column": "views", "operator": "gte", "value": 100 } |
lt | < | { "column": "price", "operator": "lt", "value": 50 } |
lte | <= | { "column": "price", "operator": "lte", "value": 50 } |
like | LIKE | { "column": "title", "operator": "like", "value": "%search%" } |
ilike | ILIKE | { "column": "title", "operator": "ilike", "value": "%search%" } (case-insensitive) |
is | IS NULL | { "column": "deleted_at", "operator": "is", "value": null } |
in | IN | { "column": "id", "operator": "in", "value": ["id1", "id2"] } |
Complete Examples
E-commerce Order Search
const response = await fetch(`${apiUrl}/databases/${databaseName}/query/select`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
table: 'orders',
select: ['id', 'order_number', 'customer_name', 'total', 'status', 'created_at'],
filters: [
{ column: 'status', operator: 'neq', value: 'cancelled' },
{ column: 'total', operator: 'gte', value: 50 },
{ column: 'created_at', operator: 'gte', value: '2026-01-01' },
{ column: 'customer_name', operator: 'ilike', value: '%smith%' },
],
order: [{ column: 'created_at', ascending: false }],
limit: 100,
}),
});
const { data, count } = await response.json();
console.log(`Found ${count} orders:`, data);Social Media Feed
const response = await fetch(`${apiUrl}/databases/${databaseName}/query/select`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
table: 'posts',
select: ['id', 'content', 'like_count', 'comment_count', 'created_at'],
filters: [
{ column: 'like_count', operator: 'gt', value: 10 },
{ column: 'visibility', operator: 'eq', value: 'public' },
{ column: 'deleted_at', operator: 'is', value: null },
],
order: [
{ column: 'like_count', ascending: false },
{ column: 'created_at', ascending: false },
],
limit: 50,
}),
});
const { data } = await response.json();
// data is automatically filtered to posts user has access to (RLS)Create a Post
const response = await fetch(`${apiUrl}/databases/${databaseName}/query/insert`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
table: 'posts',
values: {
title: 'My New Post',
content: 'This is my first post!',
status: 'published',
},
}),
});
const { data } = await response.json();
const newPost = data[0];
console.log('Created post:', newPost.id);
// user_id is automatically set to your user ID!Update a Post
const response = await fetch(`${apiUrl}/databases/${databaseName}/query/update`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
table: 'posts',
values: {
title: 'Updated Title',
status: 'published',
},
filters: [{ column: 'id', operator: 'eq', value: postId }],
}),
});
const { data } = await response.json();
// RLS ensures you can only update your own postsDelete a Post
const response = await fetch(`${apiUrl}/databases/${databaseName}/query/delete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
table: 'posts',
filters: [{ column: 'id', operator: 'eq', value: postId }],
}),
});
const { data } = await response.json();
// RLS ensures you can only delete your own postsUsing with Volcano SDK
The @volcano.dev/sdk makes this even simpler:
import { VolcanoAuth } from '@volcano.dev/sdk';
const volcano = new VolcanoAuth({
apiUrl: 'https://api.yourapp.com',
anonKey: 'your-anon-key',
});
// Sign in
await volcano.auth.signIn({
email: 'user@example.com',
password: 'password',
});
// Query database (future SDK feature)
const posts = await volcano
.from('posts')
.select('id, title, content')
.eq('status', 'published')
.order('created_at', { ascending: false })
.limit(10);Row-Level Security
A request with a user's access token runs as authenticated, so the RLS policies on your tables apply to it. A request with a service key holding * bypasses RLS. Until Volcano applies service key scopes to the database API, so does every other service key; after that, a key scoped without * gets 403. RLS applies only to tables where you enable it: on a table without RLS, any user session can read, change, and delete every row. See Default grants.
How It Works
- You sign in - Receive an access token with your user ID
- You query - Send access token with request
- Volcano validates - Checks your token and identity
- Your identity is injected -
auth.uid()returns your user ID - RLS filters data - On tables with RLS enabled, you see only what your policies allow
Example RLS Policy
-- Create policy on your table
CREATE POLICY "users_own_posts" ON posts
FOR ALL
USING (user_id = auth.uid());Result: Each user automatically sees only their own posts!
No manual work required
You don't need to:
- Manually add
WHERE user_id = $1to queries - Pass user ID in every request
- Worry about forgetting security filters
Volcano automatically:
- Injects your user ID into the database session
- Ensures
auth.uid()returns your ID - Lets RLS policies filter data
Pagination
Combine limit and offset for pagination:
// Page 1 (rows 1-10)
const page1 = await query({
table: 'posts',
limit: 10,
offset: 0,
});
// Page 2 (rows 11-20)
const page2 = await query({
table: 'posts',
limit: 10,
offset: 10,
});
// Page 3 (rows 21-30)
const page3 = await query({
table: 'posts',
limit: 10,
offset: 20,
});Ordering
Single Column
{
"table": "posts",
"order": [{ "column": "created_at", "ascending": false }]
}Multiple Columns
{
"table": "posts",
"order": [
{ "column": "like_count", "ascending": false },
{ "column": "created_at", "ascending": false }
]
}With NULL Handling
{
"table": "posts",
"order": [{ "column": "priority", "ascending": false, "nulls_first": true }]
}Advanced Filtering
Multiple Conditions (AND)
All filters are combined with AND:
{
"table": "products",
"filters": [
{ "column": "category", "operator": "eq", "value": "electronics" },
{ "column": "price", "operator": "gte", "value": 100 },
{ "column": "price", "operator": "lte", "value": 500 },
{ "column": "in_stock", "operator": "eq", "value": true }
]
}SQL equivalent:
WHERE category = 'electronics'
AND price >= 100
AND price <= 500
AND in_stock = trueCase-Insensitive Search
{
"table": "users",
"filters": [{ "column": "name", "operator": "ilike", "value": "%john%" }]
}Matches: "John", "JOHN", "john", "Johnny", etc.
NULL Values
{
"table": "posts",
"filters": [{ "column": "deleted_at", "operator": "is", "value": null }]
}Returns only posts where deleted_at IS NULL (not deleted).
Date Ranges
{
"table": "orders",
"filters": [
{ "column": "created_at", "operator": "gte", "value": "2026-01-01" },
{ "column": "created_at", "operator": "lt", "value": "2026-02-01" }
]
}Returns orders created in January 2026.
Common Patterns
Get User's Recent Posts
{
"table": "posts",
"select": ["id", "title", "created_at"],
"order": [{ "column": "created_at", "ascending": false }],
"limit": 10
}With RLS enabled on posts and a policy like the one above, this returns only the current user's posts.
Search Posts by Title
{
"table": "posts",
"filters": [{ "column": "title", "operator": "ilike", "value": "%search term%" }]
}Get Popular Posts
{
"table": "posts",
"filters": [
{ "column": "like_count", "operator": "gt", "value": 100 },
{ "column": "status", "operator": "eq", "value": "published" }
],
"order": [{ "column": "like_count", "ascending": false }],
"limit": 50
}Update User Profile
{
"table": "profiles",
"values": {
"bio": "Updated bio",
"avatar_url": "https://example.com/avatar.jpg"
},
"filters": [{ "column": "id", "operator": "eq", "value": "profile-uuid" }]
}Error Handling
Authentication Errors
// 401 Unauthorized
{
"error": "Authentication required for database queries"
}Fix: Include valid access token in Authorization header
Invalid Query
// 400 Bad Request
{
"error": "Failed to build query: invalid table name"
}Fix: Ensure table and column names are valid identifiers
Database Errors
// 400 Bad Request
{
"error": "Query failed: relation \"posts\" does not exist"
}Fix: Ensure table exists in your database
Unexpected connection failures return 500 Internal Server Error with a
generic message and an X-Request-Id response header:
{
"error": "Query failed"
}Use the request ID when contacting support. Volcano keeps connection details in server logs and does not return them to clients.
If the database ends the connection under a select or ping, for example
when it scales to zero or restarts, Volcano runs the query once more on another
connection, inside a read-only transaction, before it answers. Volcano never
reruns insert, update, or delete, because the database may already have
applied the change. A select that writes, such as one on a view over a
function that inserts rows, fails the read-only rerun instead of writing twice.
These return the 500 above; retry them only when running the change twice is
safe.
Permission Denied (RLS)
// 200 OK with empty results
{
"data": [],
"count": 0
}Row-Level Security silently filters out data you don't have access to.
Security
Automatic protection
| Protection | Description |
|---|---|
| Row-level security | Applied on tables with RLS enabled |
| SQL injection prevention | All inputs validated |
| Parameterized queries | Values never interpolated |
| Identity verification | JWT validated on every request |
What's protected
Your identity:
- Extracted from your JWT access token
- Cannot be spoofed or modified
- Automatically used in RLS policies
Your data:
- RLS policies filter automatically
- You can only see/modify data you own
- No manual security checks needed
Best practices
-
Always use RLS policies on tables with user data
ALTER TABLE posts ENABLE ROW LEVEL SECURITY; CREATE POLICY "users_own_posts" ON posts FOR ALL USING (user_id = auth.uid()); -
Auto-set user_id on INSERT using triggers
CREATE TRIGGER set_user_id BEFORE INSERT ON posts FOR EACH ROW EXECUTE FUNCTION set_user_id_trigger(); -
Validate input in your application before sending queries
When to use REST API vs Direct SQL
Use REST API when:
| Use case | Description |
|---|---|
| Simple CRUD operations | SELECT, INSERT, UPDATE, DELETE |
| Frontend applications | Browser/mobile can't use direct PostgreSQL |
| Quick prototyping | No SQL knowledge required |
| Standard queries | Filtering, sorting, pagination |
Use Direct SQL when:
| Use case | Description |
|---|---|
| Complex queries | JOINs, subqueries, CTEs, window functions |
| Full PostgreSQL power | Advanced features and extensions |
| ORM integration | Sequelize, Prisma, TypeORM, etc. |
| Transactions | Multi-step operations |
| Performance | Direct connection is faster |
Both methods use the same RLS enforcement.
Response headers
Every query response reports where its time went:
X-Volcano-Proxy-Ms: milliseconds Volcano spent before running the query, counted from the moment your request arrived — authenticating the caller, validating the request, resolving the database, and building the SQL.X-Volcano-Proxy-Handler-Ms: the part ofX-Volcano-Proxy-Msspent in the query endpoint itself. Subtract it fromX-Volcano-Proxy-Msto see what authentication and request validation cost.X-Volcano-Compute-Ms: milliseconds the database took to run the query and return its rows.
All three are present whether the query succeeded or failed, so a slow statement and a slow request are easy to tell apart.
curl -sD - -X POST "https://api.volcano.dev/databases/$DATABASE/query/select" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"table":"posts"}' -o /dev/null | grep -i x-volcanoX-Volcano-Proxy-Ms: 6
X-Volcano-Proxy-Handler-Ms: 2
X-Volcano-Compute-Ms: 14This query waited 6 ms on Volcano — 4 ms of it before the query endpoint was reached — and 14 ms on the database.
Limits
- Tables: Must exist in your database
- Columns: Must be valid identifiers (alphanumeric + underscore)
- Operators: See supported operators table above
- Filters: Combined with AND (OR not yet supported)
- UPDATE/DELETE: Requires at least one filter for safety
What's next
| Guide | Description |
|---|---|
| Authentication flow | How tokens are passed to database |
| Query Builder API | Easier query builder syntax |
| Row-level security | Secure your tables |
| Auth helpers | Using auth.uid(), auth.email() |
| Authentication overview | User signup/signin |
FAQ
Q: Can I use this from the browser?
A: Yes! That's exactly what it's designed for.
Q: Is my data secure?
A: Requests with a user's access token run as authenticated, so your RLS policies apply. Enable RLS on every table first; a table without it is open to any user session. See Default grants.
Q: Do I need to write SQL?
A: No! The REST API handles SQL generation for you.
Q: Can I do complex queries with JOINs?
A: For complex queries, use functions with direct SQL instead.
Q: How fast is it?
A: Very fast! Direct connection to your database with connection pooling.
Q: Do I need a backend server?
A: No! Call the REST API directly from your frontend.