Data API
Read and write your tables over HTTPS from a browser or mobile app, with row-level security deciding access.
The Data API serves your Plexivo Postgres tables over HTTPS, so a web or mobile app can read and write them with no backend server in between. Each request runs inside your database as its caller, and PostgreSQL row-level security decides which rows that caller may touch.
Turn it on#
- Open the Connect tab of your database and find From a browser or mobile app.
- Set the Allowed web origin to the exact HTTPS address your web app runs on, for example
https://shop.example.in. A browser request from any other origin is refused; apps and servers that send no origin are not affected. Leave it empty if only servers and mobile apps call the API. - Choose Turn on Data API. Copy the Project URL, the Publishable key and the secret key now: the full keys are shown only this once.
The Data API tab then lists every table your apps can reach, its columns, and ready-to-copy code for JavaScript and Flutter built from that table’s own columns.
What apps can reach#
Nothing is exposed by accident. A table appears in the Data API only once row-level security is on and it has at least one policy. A view appears only if it runs with its caller’s rights (security_invoker), and a database function only when its comment says @expose. Until then the API answers as if the table did not exist, and the Not exposed list on the Data API tab says what to change.
New tables and policies appear within 30 seconds, or at once when you choose Reload schema or run SQL in the console’s SQL editor.
Keys and callers#
Every request carries a key in the apikey header. Who the request runs as depends on what else it carries:
| The request carries | It runs as | Use it in |
|---|---|---|
| The publishable key | anon | Your app, before anyone signs in |
The publishable key and a user’s access token as Authorization: Bearer … | authenticated, as that user | Your app, after sign-in |
| The secret key | service_role, which bypasses row-level security | Your servers only |
A token that has expired or is not valid is refused, never treated as anonymous. The secret key is never accepted in a URL, and the allowed-origin check applies only to the publishable key.
Read rows#
With the client libraries:
import { createClient } from '@plexivo/client';
const db = createClient(PLEXIVO_DATA_URL, PLEXIVO_PUBLISHABLE_KEY);
const orders = await db.from('orders')
.where('status', 'open')
.preload('customer')
.order('created_at', 'desc')
.limit(20);
// Or Supabase-style, with a total count
const { data, count } = await db.from('orders')
.select('id, total, customer:customers(name)', { count: 'exact' })
.eq('status', 'open')
.range(0, 19);final db = PlexivoClient(dataUrl, publishableKey);
final orders = await db.from('orders')
.where('status', 'open')
.preload('customer')
.order('created_at', 'desc')
.limit(20);Or over HTTPS from any language:
curl "$PLEXIVO_DATA_URL/rest/v1/orders?select=id,total,customer:customers(name)&status=eq.open&order=created_at.desc&limit=20&count=exact" \
-H "apikey: $PLEXIVO_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN"Responses are JSON: {"data": [...], "count": 42}. GET /rest/v1/orders/17 reads one row by its primary key.
The query language#
The HTTPS form uses the same query language as PostgREST, so queries written for Supabase keep working.
| Parameter | What it does |
|---|---|
select=id,title,writer:author_id | Columns, optionally renamed. * for all. |
select=id,author:authors(name) | Related rows through a foreign key, either way round: one parent as an object, many children as an array. Up to four levels deep. |
col=op.value | A condition, with not. before any operator to negate it. |
or=(a.eq.1,and(b.gt.2,c.is.null)) | Groups of conditions, nested. |
order=col.desc.nullslast,other | Sort order. |
limit, offset | Pages. limit is at most 1,000. |
count=exact | Adds the total number of matching rows. |
Operators: eq, neq, gt, gte, lt, lte, like and ilike (with * as the wildcard), match and imatch (regular expressions), in.(1,2,3), is.null (or true, false, unknown), cs (contains), cd (contained by), ov (overlaps), and full-text search with fts, plfts, phfts and wfts.
Related rows obey their own table’s row-level security: a user sees only the related rows they could read directly.
Write rows#
const order = await db.from('orders').create({ total: 1299, status: 'open' });
await db.from('products').upsert(item, { onConflict: 'sku', update: ['price'] });
await db.from('orders').where('id', order.id).update({ status: 'paid' });
await db.from('orders').where('id', order.id).delete();final order = await db.from('orders').create({'total': 1299, 'status': 'open'});
await db.from('products').upsert({'sku': 'A1', 'price': 11}, onConflict: 'sku', update: ['price']);
await db.from('orders').where('id', order['id']).update({'status': 'paid'});
await db.from('orders').where('id', order['id']).delete();Over HTTPS: POST /rest/v1/orders with one object or a list of up to 1,000; on_conflict=sku with upsert=merge or upsert=ignore for an upsert; PATCH and DELETE with filters, or on /rest/v1/orders/17. Writes return the rows they wrote; add returning=minimal to skip them.
allowGlobalUpdate().Transactions#
Several writes can commit together or not at all. Record them without awaiting; pass one result to the next with .ref(). They travel to the database as one request.
const [customer, order] = await db.transaction((tx) => {
const customer = tx.from('customers').create({ name: 'Asha' });
tx.from('orders').create({ total: 1299, customer_id: customer.ref('id') });
});Call a database function#
Keep logic that must read and decide inside the database in a SQL function, and expose it:
create function open_order_total(customer bigint) returns numeric
language sql stable security invoker
as $$ select coalesce(sum(total), 0) from orders where customer_id = customer and status = 'open' $$;
comment on function open_order_total(bigint) is '@expose';const { data } = await db.rpc('open_order_total', { customer: 7 });final total = await db.rpc('open_order_total', {'customer': 7});Over HTTPS, send the arguments as JSON to POST /rest/v1/rpc/open_order_total.
Limits#
- Up to 1,000 rows per read, write or embedded list.
- Each request has 8 seconds to run in the database by default; a longer one is stopped with
504andstatement_timeout. - Up to 600 requests a minute from one IP address; beyond that,
429andrate_limitedwith aRetry-Afterheader. - Each project has a monthly number of requests that comes with your plan. When it is reached, requests are answered with
429andproject_request_cap_reached, with aRetry-Afterheader, until the count resets at the start of the next month (UTC).
Errors#
An error comes back as {"error": {"code": "…"}, "trace_id": "…"}, with a hint where one helps. Quote the trace_id to support. The client libraries raise it as PlexivoError in JavaScript and PlexivoException in Dart, with codes such as unique_violation, permission_denied, table_not_exposed and missing_where.
renderApiTypes from @plexivo/client/generate-types, and every table name and column is checked as you type.Next steps#
Updated 27 October 2026