Data API
The tables an access token reaches, the headers every request needs, and how to pick a schema.
Where requests go
Once you hold an access token, you talk to the database directly at https://nvkkanirwnjjvuwxsjch.supabase.co/rest/v1 rather than through our own routes. It is a PostgREST interface over the same tables the dashboard reads, so a table name is a path, a column filter is a query parameter, and the rows you get back are the rows your workspace is allowed to see. The playground on this site drives exactly this surface, and the curl it prints under each call is the call you can paste into a terminal.
Headers
| Header | Value | When |
|---|---|---|
| apikey | The publishable key | Always. It identifies the project. |
| Authorization | Bearer plus the access token | Always. It identifies you. |
| Accept-Profile | public / calls_history | On reads, to name the schema. |
| Content-Profile | public / calls_history | On writes, to name the schema. |
| Prefer | return=representation | Optional on a write, when you want the created or updated row back. |
Name the schema
Orders, clients, campaigns and account data live in public. Everything the agents produce, calls, transcripts, messages and outcomes, lives in calls_history. An unqualified request resolves against whichever schema is exposed first, and that is not guaranteed to be public, so name it on every call. If you skip it and get a 404 reading Could not find the table 'api.orders', that mismatch is why. In supabase-js the equivalent is .schema("calls_history"), or db: { schema: "public" } once when you create the client.
Tables you can reach
Verbs a table refuses are left out rather than listed and denied. A ledger like credits is written by billing, never by hand.
| Table | Methods | Holds |
|---|---|---|
| public.orders | GET, POST, PATCH, DELETE | Orders in your workspace. Creating one here inserts the row directly, which skips the phone validation and agent assignment that POST /api/orders performs, so it will not be queued for a confirmation call. |
| public.clients | GET, POST, PATCH, DELETE | People your campaigns call. |
| public.campaigns | GET, POST, PATCH, DELETE | Outbound campaigns and whether they are running. |
| public.campaign_clients | GET, POST, DELETE | The join between a campaign and the clients on it, carrying each one's status and the operation that called them. No workspace column of its own, so it is scoped through the campaign it belongs to. |
| calls_history.voice_agents | GET, POST, PATCH | Your voice and WhatsApp agents. Create and edit them here. is_orders_handler marks the one that picks up new orders, and there should be at most one per workspace. A direct insert skips the plan limit check and the number assignment the Agents page performs, so an agent made here starts with no WhatsApp number attached. |
| calls_history.operations | GET | Every call and chat the agents ran. Written by the platform as calls happen, so it is read-only here. |
| calls_history.transcripts | GET | What was said on a call. Filter by operation with operation_id=eq.<id> rather than listing the table. |
| calls_history.messages | GET | Chat turns for an operation. Same idea as transcripts, for the chat channels. |
| calls_history.leads | GET | Leads captured by the agents. |
| calls_history.outcome | GET | How each operation resolved: confirmed, and the failure_reason when it was not. |
| calls_history.whatsapp_conversations | GET | Conversation threads behind the chat operations. |
| public.credits | GET | Credit ledger entries. Read-only: balances move through billing and call accounting, never by hand. |
| public.subscriptions | GET | Your active plan, as Stripe reports it. Written by the billing webhook. |
| public.workspaces | GET, POST, PATCH | The workspaces you belong to. Creating one is a function call, not an insert, and it also writes your owner row in roles. |
| public.roles | GET | Who belongs to which workspace, and as what. Creating a workspace writes your owner row here for you. Membership beyond that is changed through the Members page, not here. |
| public.notifications | GET, PATCH | Workspace notifications. PATCH hidden to dismiss one. |
| public.api_keys | GET | Your own keys. key_hash is not readable by any account, so selecting it should fail. Create and revoke through /api/api-keys, which is session-authenticated. |
A read and a write
# Orders, newest first.
curl "https://nvkkanirwnjjvuwxsjch.supabase.co/rest/v1/orders?select=id,client_name,status,created_at&order=created_at.desc&limit=5" \
-H "apikey: $PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept-Profile: public"
# Mark one confirmed.
curl -X PATCH "https://nvkkanirwnjjvuwxsjch.supabase.co/rest/v1/orders?id=eq.3f1c..." \
-H "apikey: $PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Profile: public" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{ "status": "confirmed" }'A PATCH or DELETE without a filter applies to every row the policies allow. Always send a filter.
The same thing in supabase-js
import { createClient } from "@supabase/supabase-js"
const supabase = createClient(
"https://nvkkanirwnjjvuwxsjch.supabase.co",
process.env.SUPABASE_PUBLISHABLE_KEY!,
{ accessToken: async () => getAccessToken() },
)
const { data: orders } = await supabase
.from("orders")
.select("id, client_name, status, created_at")
.order("created_at", { ascending: false })
.limit(5)
// Call history lives in the other schema.
const { data: operations } = await supabase
.schema("calls_history")
.from("operations")
.select("id, status, created_at")
.limit(5)accessToken is a function and the client calls it before every request, so putting the caching from Authentication inside it is all the token lifetime handling you need. Do not pass the token as a custom Authorization header, that pattern is deprecated.
When something fails
| Symptom | Cause |
|---|---|
| 401 "JWT expired" | The token outlived its hour. Exchange the key again. |
| 401 "No API key found in request" | The apikey header is missing. Both headers are required, not either one. |
| PGRST106 | The schema in your profile header is not exposed by the project. |
| An empty array where you expected your own rows | The table's policies scope to something other than your user, or grant nothing to authenticated. Check the same query in the playground. |
Inserting orders
One thing the Data API cannot do for you: a row inserted straight into orders is stored and readable, but it skips the phone normalisation and the agent assignment that the dashboard performs, so it is never queued for a confirmation call. Insert directly for testing and for data you only want to read back. Orders that need to be called should be created in the dashboard or through an import until an order creation endpoint that takes an access token exists.