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#

  1. Open the Connect tab of your database and find From a browser or mobile app.
  2. 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.
  3. 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 carriesIt runs asUse it in
The publishable keyanonYour app, before anyone signs in
The publishable key and a user’s access token as Authorization: Bearer …authenticated, as that userYour app, after sign-in
The secret keyservice_role, which bypasses row-level securityYour 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.

Keep the secret key on your serversPut it in Plexivo Secrets, never in a browser, a mobile app or a public repository.

Read rows#

With the client libraries:

js
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);
dart
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:

bash
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.

ParameterWhat it does
select=id,title,writer:author_idColumns, 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.valueA 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,otherSort order.
limit, offsetPages. limit is at most 1,000.
count=exactAdds 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#

js
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();
dart
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.

Updates and deletes need a filterAn update or delete that would touch every row is refused, not run. The client libraries refuse it before sending, unless you opt in with 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.

js
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:

sql
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';
js
const { data } = await db.rpc('open_order_total', { customer: 7 });
dart
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 504 and statement_timeout.
  • Up to 600 requests a minute from one IP address; beyond that, 429 and rate_limited with a Retry-After header.
  • Each project has a monthly number of requests that comes with your plan. When it is reached, requests are answered with 429 and project_request_cap_reached, with a Retry-After header, 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.

For startups Generate TypeScript types from your live API with renderApiTypes from @plexivo/client/generate-types, and every table name and column is checked as you type.

Next steps#

Updated 27 October 2026