
Supabase REST API: Quickstart with curl and JavaScript
Every Supabase project comes with a REST API, the Data API, generated from your Postgres schema by PostgREST. It lives at https://<project-ref>.supabase.co/rest/v1/<table>. You call it with your project's API key in the apikey header, and Row Level Security (RLS) decides which rows each caller can read or change. The shortest working Supabase REST API example is one line: curl "https://<project-ref>.supabase.co/rest/v1/leaderboard?select=*" -H "apikey: sb_publishable_...". This guide covers where to find the Supabase API URL and keys, how the new publishable and secret keys differ from the legacy anon and service_role keys, and copy-paste curl, fetch and supabase-js examples for select, filters, joins, pagination, insert, upsert, update, delete, RPC and Auth. Everything was checked against the Supabase and PostgREST docs as of November 2026.
Supabase REST API capabilities at a glance
"The Supabase API" is really several HTTP services behind one gateway on your project domain. The REST endpoint for tables is only one of them. Here is the full map, so you know which base path a request belongs to:
| Service | Base path | What it does |
|---|---|---|
| Data API (PostgREST) | /rest/v1/ |
CRUD on tables and views, joins, filters, RPC calls to Postgres functions |
| Auth | /auth/v1/ |
Sign-up, password sign-in, OTP and magic links, token refresh, the current user |
| Storage | /storage/v1/ |
Upload and download files in buckets |
| Edge Functions | /functions/v1/<name> |
Your own TypeScript endpoints for custom logic |
| GraphQL | /graphql/v1 |
pg_graphql over the same schema (no longer enabled by default on new projects since May 2026) |
| Realtime | /realtime/v1/ |
WebSocket subscriptions to changes, broadcast and presence |
| Management API | https://api.supabase.com/v1/ |
Manage projects, keys and settings with a personal access token |
All of the project services share one host, https://<project-ref>.supabase.co, and all of them want the apikey header. The rest of this page is about /rest/v1/ plus the Auth calls you need to get a user token for it.
Supabase REST API capabilities in one list, per the API docs:
- CRUD on tables, plus views, materialized views and foreign tables
- Nested reads across foreign keys (joins) in a single request
- Postgres functions callable over HTTP
- Computed columns and computed relationships
- Postgres security: RLS, roles and grants
- Each request resolves to a single SQL statement
What the Supabase Data API is
According to the Supabase API docs, Supabase builds the API from your database structure. Create a table or a view and it becomes a REST endpoint. Schema changes show up in the API right away, and you don't write any server code. Under the hood it is PostgREST, so the URL filter syntax (?id=eq.1, ?order=score.desc) and the error codes are PostgREST's. Supabase moved every project to PostgREST v14 in December 2025, so the PostgREST v14 docs are the right reference.
The Data API is what the supabase-js client calls when you run supabase.from('table'). You can also call it directly with curl, fetch, or any HTTP client. Supabase describes two ways to use it: straight from the browser or mobile app (two tiers, with RLS as the security boundary), or behind your own server (three tiers, where your backend calls it with a secret key).
Where to find the Supabase API URL and API keys
- Open your project in the Supabase Dashboard.
- Click Connect at the top of the project page. The dialog shows the project URL (
https://<project-ref>.supabase.co) and the publishable key. - For every key, including legacy ones, go to Settings > API Keys.
- The Data API settings (exposed schemas, the API URL, the Enable Data API switch) are under Integrations > Data API.
You can also list keys with the CLI: supabase projects api-keys --project-ref <ref>. With a personal access token you can read them from the Management API at GET /v1/projects/{ref}/api-keys (API keys docs). A local stack (supabase start or supabase status) prints its own local URL and keys, which are separate from the hosted ones.
Supabase URL format. The project URL is always https://<project-ref>.supabase.co, where the project ref is the ID in your dashboard URL. A Supabase URL example for a table called leaderboard is https://abcdefghijklmnopqrst.supabase.co/rest/v1/leaderboard. If you use a custom domain, swap the host and keep the /rest/v1/ path.
Publishable and secret keys vs legacy anon and service_role
Supabase now issues two kinds of keys (API keys docs):
| Key | Format | Where it goes | Postgres role | What it can do |
|---|---|---|---|---|
| Publishable | sb_publishable_... |
Browser, mobile app, CLI | anon, or authenticated with a user token |
Low privilege. RLS limits it. |
| Secret | sb_secret_... |
Servers, Edge Functions, cron jobs | service_role |
Bypasses RLS. Never ship it to a client. |
Legacy anon |
JWT starting eyJ |
Same as publishable | anon |
Same role as publishable |
Legacy service_role |
JWT starting eyJ |
Same as secret | service_role |
Same role as secret |
Things to know:
- Supabase plans to deprecate the legacy
anonandservice_rolekeys by the end of 2026. Creating new keys does not turn the old ones off. You deactivate them yourself in Settings > API Keys once nothing uses them. - If a browser sends a secret key, Supabase detects it from the
User-Agentand returns401. That does not protect a leaked key, since anyone can send it with curl. Delete a leaked secret key immediately. - Send publishable and secret keys in the
apikeyheader. They are not JWTs, so don't put them inAuthorization: Bearer. With the legacy keys, many examples sent the same key in both headers. That pattern doesn't carry over. - You can create several secret keys. Supabase recommends one per backend component, so a leak means rotating only that one.
- In Edge Functions, read secret keys from the
SUPABASE_SECRET_KEYSenvironment variable.SUPABASE_ANON_KEYandSUPABASE_SERVICE_ROLE_KEYstill exist but are the legacy path.
Supabase PAT (personal access token) is a different thing. A PAT, created under your account's Access Tokens page, authenticates you to the Management API (api.supabase.com), the CLI and tools that manage projects. It is not used for the Data API and should never be put in an app.
Recent changes that affect REST API code, in order:
- Dec 11, 2025The Data API moves to PostgREST v14. No breaking changes expected; a JWT cache gives about 20% more GET throughput in Supabase's benchmark.
- Feb 17, 2026Supabase announces that the OpenAPI spec at
/rest/v1/will stop answering theanonkey. - Apr 8, 2026All projects:
GET /rest/v1/with theanonkey returns "Access to schema is forbidden". Use a secret key on a server, or the Management API endpointGET /v1/projects/{ref}/database/openapi. - May 30, 2026New projects stop exposing new
publictables automatically (discussion #45329). - Oct 30, 2026The same applies to new tables in existing projects. Existing tables keep their grants.
- End of 2026Planned deprecation of the legacy
anonandservice_roleJWT keys (API keys docs).
Before your first request: grants and RLS
Two layers decide whether a request works (Securing your API):
- Grants decide whether a role (
anon,authenticated,service_role) can touch the table at all. - RLS policies decide which rows that role sees or changes.
Supabase is changing the default grants (announcement). New projects created since May 30, 2026 no longer expose new public tables automatically, and from October 30, 2026 the same applies to new tables in existing projects. Existing tables keep their grants. If your new table returns a permission error, grant access explicitly. The Data API's 42501 error includes a hint with the grant statement it needs.
The examples on this page use two tables: teams and a leaderboard that points at them.
create table public.teams (
id bigint generated always as identity primary key,
name text not null unique
);
create table public.leaderboard (
id bigint generated always as identity primary key,
player text not null unique,
score int not null default 0,
team_id bigint references public.teams (id),
metadata jsonb not null default '{}',
user_id uuid default auth.uid(),
created_at timestamptz not null default now()
);
alter table public.teams enable row level security;
alter table public.leaderboard enable row level security;
grant select on public.teams, public.leaderboard to anon, authenticated;
grant insert, update, delete on public.leaderboard to authenticated;
grant select, insert, update, delete on public.teams, public.leaderboard to service_role;
create policy "anyone can read teams" on public.teams
for select to anon, authenticated using (true);
create policy "anyone can read" on public.leaderboard
for select to anon, authenticated using (true);
create policy "signed-in users can write" on public.leaderboard
for insert to authenticated with check (auth.uid() = user_id);
create policy "users update their own rows" on public.leaderboard
for update to authenticated
using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "users delete their own rows" on public.leaderboard
for delete to authenticated using (auth.uid() = user_id);
Tables created in the Table Editor have RLS on by default. Tables created in SQL do not, so always add the enable row level security line. A table with RLS on and no policy returns no rows to anon and authenticated.
curl examples: select, insert, update, delete
Set two shell variables first:
export SB_URL="https://<project-ref>.supabase.co"
export SB_KEY="sb_publishable_..."
Select (with columns, a filter, ordering and a limit):
curl "$SB_URL/rest/v1/leaderboard?select=player,score&score=gte.100&order=score.desc&limit=10" \
-H "apikey: $SB_KEY"
The response is a JSON array:
[
{ "player": "carol", "score": 5100 },
{ "player": "alice", "score": 4200 }
]
Common filter operators: eq, neq, gt, gte, lt, lte, like, ilike, in.(a,b), is.null. They all follow the column=operator.value pattern. The full list is in the cheat sheet below.
Get one row as an object, not an array:
curl "$SB_URL/rest/v1/leaderboard?id=eq.1" \
-H "apikey: $SB_KEY" \
-H "Accept: application/vnd.pgrst.object+json"
If the filter matches zero or several rows, this returns 406 with code PGRST116.
Insert (Prefer: return=representation returns the new row; the default is return=minimal, which returns 201 and no body):
curl -X POST "$SB_URL/rest/v1/leaderboard" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"player": "ada", "score": 120}'
Update (always filter, or you target every row you are allowed to change):
curl -X PATCH "$SB_URL/rest/v1/leaderboard?id=eq.1" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"score": 150}'
Delete:
curl -X DELETE "$SB_URL/rest/v1/leaderboard?id=eq.1" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"
Upsert: send a POST with Prefer: resolution=merge-duplicates. A row whose primary key already exists is updated instead of rejected. Details and the on_conflict variant are in the upsert section below.
Export CSV: add -H "Accept: text/csv" to any select to get CSV instead of JSON.
Auth headers in short
apikeyis required on every request. It identifies the project and sets the base role.Authorization: Bearer <access_token>is optional. It carries a signed-in user's JWT from Supabase Auth, so RLS policies run asauthenticatedandauth.uid()returns that user. Without it, the request runs asanon.- On a server, sending the secret key in
apikey(and no user token) runs asservice_roleand skips RLS.
| Request sends | Role in Postgres | RLS |
|---|---|---|
apikey: sb_publishable_... |
anon |
Applies |
apikey: sb_publishable_... + Authorization: Bearer <user JWT> |
authenticated (auth.uid() = that user) |
Applies |
apikey: sb_secret_... |
service_role |
Bypassed |
No apikey |
Rejected with 401 |
n/a |
To see what a user token contains (role, sub, expiry), paste it into the JWT decoder. It runs in your browser, but still don't paste production tokens anywhere you don't control.
Filtering operators: the full cheat sheet
Every filter is a query parameter in the form column=operator.value. Several filters are combined with AND. This table is the PostgREST v14 operator list with a Supabase-flavored example for each, plus the matching supabase-js method:
| Operator | Meaning | REST example | supabase-js |
|---|---|---|---|
eq |
equals | ?player=eq.ada |
.eq('player', 'ada') |
neq |
not equal | ?player=neq.ada |
.neq('player', 'ada') |
gt, gte |
greater than (or equal) | ?score=gte.100 |
.gte('score', 100) |
lt, lte |
less than (or equal) | ?score=lt.50 |
.lt('score', 50) |
like, ilike |
pattern match (case-sensitive / insensitive), * as wildcard |
?player=ilike.*ad* |
.ilike('player', '%ad%') |
match, imatch |
regex match (~, ~*) |
?player=match.^a |
.match() is different; use .filter('player', 'match', '^a') |
in |
one of a list | ?id=in.(1,2,3) |
.in('id', [1, 2, 3]) |
is |
null, not_null, true, false, unknown |
?team_id=is.null |
.is('team_id', null) |
isdistinct |
not equal, treating NULL as a value | ?team_id=isdistinct.2 |
.filter('team_id', 'isdistinct', 2) |
fts, plfts, phfts, wfts |
full-text search (to_tsquery, plainto_, phraseto_, websearch_to_tsquery) |
?bio=wfts(english).fast database |
.textSearch('bio', 'fast database', { type: 'websearch', config: 'english' }) |
cs, cd |
array or range contains / contained by | ?tags=cs.{pro,beta} |
.contains('tags', ['pro', 'beta']) |
ov |
overlap | ?tags=ov.{pro,beta} |
.overlaps('tags', ['pro', 'beta']) |
sl, sr, nxr, nxl, adj |
range comparisons | ?period=sl.[1,10) |
.rangeLt('period', '[1,10)') |
not. |
negate any operator | ?team_id=not.is.null |
.not('team_id', 'is', null) |
OR, AND and nesting. Use or=(...) and and=(...) with column.operator.value inside:
# score >= 1000 OR player is ada
curl --get "$SB_URL/rest/v1/leaderboard" -H "apikey: $SB_KEY" \
--data-urlencode "or=(score.gte.1000,player.eq.ada)"
# team 1 AND (score < 10 OR score > 5000)
curl --get "$SB_URL/rest/v1/leaderboard" -H "apikey: $SB_KEY" \
--data-urlencode "team_id=eq.1" \
--data-urlencode "or=(score.lt.10,score.gt.5000)"
In supabase-js that is .or('score.gte.1000,player.eq.ada').
any and all modifiers. ?player=like(any).{a*,b*} matches players starting with a or b. like(all) requires every pattern to match. They work with eq, like, ilike, gt, gte, lt, lte, match and imatch.
JSON columns. Use -> for JSON and ->> for text, in select, filters and order:
curl --get "$SB_URL/rest/v1/leaderboard" -H "apikey: $SB_KEY" \
--data-urlencode "select=player,plan:metadata->>plan" \
--data-urlencode "metadata->>plan=eq.pro"
Ordering. order=score.desc,player.asc. Add nullsfirst or nullslast: order=team_id.asc.nullslast.
Select, rename and embed related tables
The select parameter does more than pick columns (PostgREST docs):
- Rename:
select=name:player,points:score - Cast:
select=score::text - JSON paths:
select=metadata->>plan
Embedding (joins). PostgREST reads your foreign keys and lets you pull related rows in the same request (resource embedding). Because leaderboard.team_id references teams.id:
# many-to-one: each score with its team as an object
curl "$SB_URL/rest/v1/leaderboard?select=player,score,team:teams(name)" -H "apikey: $SB_KEY"
# one-to-many: each team with an array of its players
curl "$SB_URL/rest/v1/teams?select=name,leaderboard(player,score)" -H "apikey: $SB_KEY"
Result of the first call:
[{ "player": "carol", "score": 5100, "team": { "name": "Red" } }]
Shape the embedded rows with prefixed parameters. These change what is inside the embed, not which parent rows come back:
curl --get "$SB_URL/rest/v1/teams" -H "apikey: $SB_KEY" \
--data-urlencode "select=name,leaderboard(player,score)" \
--data-urlencode "leaderboard.order=score.desc" \
--data-urlencode "leaderboard.limit=3"
To filter the parents by a child value, add !inner, which turns the embed into an inner join:
# only teams that have a player called ada
curl --get "$SB_URL/rest/v1/teams" -H "apikey: $SB_KEY" \
--data-urlencode "select=name,leaderboard!inner(player)" \
--data-urlencode "leaderboard.player=eq.ada"
Other embedding tricks: leaderboard=is.null returns teams with no players (an anti-join), order=teams(name).asc sorts by a to-one relation, and the spread operator select=player,...teams(team_name:name) flattens the team columns into the parent object. Many-to-many works through a junction table with foreign keys to both sides.
Pagination with limit, offset and the Range header
Supabase caps every response at the project's Max rows setting, 1,000 by default (Supabase reference), and Supabase recommends keeping it low. A request for 5,000 rows silently returns the first 1,000, so page through big tables. There are two equivalent ways (PostgREST pagination docs):
# query parameters: rows 31-45
curl "$SB_URL/rest/v1/leaderboard?select=*&order=id&limit=15&offset=30" -H "apikey: $SB_KEY"
# Range header: zero-based and inclusive, so 0-19 is the first 20 rows
curl "$SB_URL/rest/v1/leaderboard?select=*&order=id" \
-H "apikey: $SB_KEY" \
-H "Range-Unit: items" \
-H "Range: 0-19"
Every response carries a Content-Range header such as 0-19/*. The * means no total was requested. To get the total, add a count preference:
curl -i "$SB_URL/rest/v1/leaderboard?select=id&limit=20" \
-H "apikey: $SB_KEY" \
-H "Prefer: count=exact"
# Content-Range: 0-19/4213
Prefer value |
Speed | Accuracy |
|---|---|---|
count=exact |
Slow on big tables (runs count(*)) |
Exact |
count=planned |
Fast | Estimate from Postgres statistics (run ANALYZE to refresh) |
count=estimated |
Fast | Exact below the max-rows threshold, planned above it |
For a count with no rows at all, send a HEAD request (curl -I) with Prefer: count=exact. A range past the end returns 416 with code PGRST103.
Upsert, bulk insert and returning rows
Bulk insert. Send a JSON array. All objects should have the same keys:
curl -X POST "$SB_URL/rest/v1/leaderboard" \
-H "apikey: $SB_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '[{"player": "alice", "score": 4200}, {"player": "bob", "score": 3700}]'
PostgREST also accepts CSV with a header row (Content-Type: text/csv), which is handy for imports. The CSV to JSON tool converts a file if you'd rather send JSON.
Upsert on the primary key. Prefer: resolution=merge-duplicates updates rows whose primary key already exists. The primary key must be in the body:
curl -X POST "$SB_URL/rest/v1/leaderboard" \
-H "apikey: $SB_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates,return=representation" \
-d '[{"id": 1, "player": "alice", "score": 4300}]'
Upsert on another unique column. Add on_conflict=<column>. The column needs a unique constraint (player has one in the schema above):
curl -X POST "$SB_URL/rest/v1/leaderboard?on_conflict=player" \
-H "apikey: $SB_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates" \
-d '{"player": "alice", "score": 4400}'
Use Prefer: resolution=ignore-duplicates to skip existing rows instead of updating them. When some rows in a bulk upsert have no id and should get the default, add missing=default to the Prefer header.
Useful Prefer values for writes (PostgREST preferences):
Prefer |
Effect |
|---|---|
return=minimal |
No body (the default for writes) |
return=representation |
Return the affected rows; combine with select= to pick columns |
return=headers-only |
Return a Location header for the new row |
resolution=merge-duplicates / ignore-duplicates |
Upsert behavior |
missing=default |
Use column defaults for keys missing from some objects |
handling=strict + max-affected=<n> |
Fail with PGRST124 if a PATCH or DELETE would touch more than n rows |
Calling Postgres functions with RPC
Anything the URL syntax can't express (transactions over several tables, aggregates, custom checks) goes into a Postgres function, which the Data API exposes at /rest/v1/rpc/<function> (PostgREST functions docs):
create function public.top_players(min_score int)
returns setof public.leaderboard
language sql stable
as $$
select * from public.leaderboard where score >= min_score order by score desc;
$$;
revoke execute on function public.top_players(int) from public;
grant execute on function public.top_players(int) to anon, authenticated;
Call it with POST and a JSON body, or with GET and query parameters when the function doesn't modify data:
curl -X POST "$SB_URL/rest/v1/rpc/top_players" \
-H "apikey: $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"min_score": 1000}'
# GET works for read-only functions, and table filters apply to the result
curl "$SB_URL/rest/v1/rpc/top_players?min_score=1000&select=player,score&limit=5" \
-H "apikey: $SB_KEY"
A function that returns a scalar (returns int) responds with the bare value, such as 3. A function that returns a table type can be filtered, ordered and embedded like a table. supabase-js calls it with supabase.rpc('top_players', { min_score: 1000 }).
The same thing with supabase-js
Install the client with npm install @supabase/supabase-js, then:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://<project-ref>.supabase.co',
'sb_publishable_...'
);
// select
const { data: top, error } = await supabase
.from('leaderboard')
.select('player, score')
.gte('score', 100)
.order('score', { ascending: false })
.limit(10);
// insert and return the new row
const { data: row } = await supabase
.from('leaderboard')
.insert({ player: 'ada', score: 120 })
.select()
.single();
// update
await supabase.from('leaderboard').update({ score: 150 }).eq('id', 1);
// delete
await supabase.from('leaderboard').delete().eq('id', 1);
More of the REST features above, in supabase-js form:
// join, page 2 (rows 20-39) and the total count in one call
const { data, count } = await supabase
.from('leaderboard')
.select('player, score, team:teams(name)', { count: 'exact' })
.order('score', { ascending: false })
.range(20, 39);
// count only, no rows (a HEAD request)
const { count: total } = await supabase
.from('leaderboard')
.select('*', { count: 'exact', head: true });
// upsert on a unique column
await supabase
.from('leaderboard')
.upsert({ player: 'alice', score: 4400 }, { onConflict: 'player' });
// filter parents by a child with an inner join
const { data: teams } = await supabase
.from('teams')
.select('name, leaderboard!inner(player)')
.eq('leaderboard.player', 'ada');
// RPC
const { data: best } = await supabase.rpc('top_players', { min_score: 1000 });
// zero-or-one row without a PGRST116 error
const { data: maybe } = await supabase
.from('leaderboard').select().eq('player', 'zed').maybeSingle();
The client adds the apikey header for you. Once a user signs in with supabase.auth, it also sends their access token, so RLS sees them as authenticated. Note that supabase-js returns errors in error instead of throwing, so check it on every call.
Plain fetch, no SDK (works in browsers, Node 18+, Deno, Bun and Cloudflare Workers):
const SB_URL = 'https://<project-ref>.supabase.co';
const SB_KEY = 'sb_publishable_...';
async function sb(path, { method = 'GET', body, token, prefer } = {}) {
const res = await fetch(SB_URL + '/rest/v1/' + path, {
method,
headers: {
apikey: SB_KEY,
'Content-Type': 'application/json',
...(token && { Authorization: 'Bearer ' + token }),
...(prefer && { Prefer: prefer }),
},
body: body && JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json(); // { code, message, details, hint }
throw new Error(res.status + ' ' + err.code + ': ' + err.message);
}
const text = await res.text(); // writes with return=minimal have no body
return text ? JSON.parse(text) : null;
}
const top = await sb('leaderboard?select=player,score&order=score.desc&limit=10');
const [created] = await sb('leaderboard', {
method: 'POST',
body: { player: 'ada', score: 120 },
token: accessToken,
prefer: 'return=representation',
});
Python. With the SDK, supabase-py works the same way (from supabase import create_client, then supabase.table('leaderboard').select('*').order('score', desc=True).execute()). Without it, requests is enough:
import requests
SB_URL = "https://<project-ref>.supabase.co"
SB_KEY = "sb_publishable_..."
r = requests.get(
f"{SB_URL}/rest/v1/leaderboard",
headers={"apikey": SB_KEY},
params={"select": "player,score", "score": "gte.100", "order": "score.desc", "limit": 10},
)
r.raise_for_status()
print(r.json())
requests URL-encodes the parameters for you, which avoids most PGRST100 parse errors.
Supabase REST API authentication, user store features and Auth endpoints
The Supabase REST API and authentication features share one gateway: Supabase Auth is the user store (users, passwords, OTP codes, OAuth identities, sessions), and the Data API trusts the access tokens it issues. To act as a user over raw HTTP, get a token from /auth/v1/ and pass it to /rest/v1/ as Authorization: Bearer. The request bodies below follow the Supabase Auth server README.
Sign up with email and password:
curl -X POST "$SB_URL/auth/v1/signup" \
-H "apikey: $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "password": "a-long-password"}'
Sign in with a password (returns access_token, refresh_token, expires_in and token_type):
curl -X POST "$SB_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "password": "a-long-password"}'
Use the token on the Data API:
export USER_ACCESS_TOKEN="eyJ..." # access_token from the response above
curl "$SB_URL/rest/v1/leaderboard?select=*&user_id=eq.<the user's id>" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"
Refresh before it expires:
curl -X POST "$SB_URL/auth/v1/token?grant_type=refresh_token" \
-H "apikey: $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"refresh_token": "<refresh_token>"}'
Email OTP or magic link. A common mistake is pointing OTP calls at <project-ref>.supabase.co/rest/v1/otp: clients get a 404 because OTP belongs to Auth, not the Data API. The right flow is two calls to /auth/v1/:
# 1. send the code (creates the user if needed)
curl -X POST "$SB_URL/auth/v1/otp" \
-H "apikey: $SB_KEY" -H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "create_user": true}'
# 2. exchange the code for a session
curl -X POST "$SB_URL/auth/v1/verify" \
-H "apikey: $SB_KEY" -H "Content-Type: application/json" \
-d '{"type": "email", "email": "ada@example.com", "token": "123456"}'
Current user and sign-out: GET /auth/v1/user and POST /auth/v1/logout, both with the user's Authorization: Bearer header. Logout revokes the refresh tokens; already-issued access tokens stay valid until they expire.
Rate limits, timeouts and pricing
The Data API has no documented per-request rate limit, and Supabase pricing says all plans include unlimited API requests. What limits you in practice is the database itself (compute size, connections), egress (5 GB on Free, 250 GB on Pro) and the statement timeouts per role. If you need per-IP or per-user limits on writes, Supabase's Securing your API guide shows a db_pre_request function that counts requests and rejects extra ones (their example allows 100 writes per IP per 5 minutes). It can only limit POST, PUT, PATCH and DELETE.
service_role has no explicit value and falls back to the authenticator role's 8 s. postgres is capped by the 2 min global default. Source: Supabase: Timeouts
A slow query through the API fails with Postgres code 57014 ("canceling statement due to statement timeout"). Add an index, or raise the role's timeout with alter role authenticated set statement_timeout = '15s'; followed by notify pgrst, 'reload config';.
Auth endpoints do have rate limits (Auth rate limits), and going over returns 429 Too Many Requests:
Most limits are customizable in the dashboard. Email sending is also capped at 2 per hour per project with the built-in email provider. Source: Supabase: Auth rate limits
Per-user cooldowns also apply: a new OTP, signup confirmation or password reset for the same user needs a 60-second gap. The built-in email sender's 2 emails per hour is the limit people hit first; set up custom SMTP before launch.
| Plan (as of November 2026) | Price | API requests | Database | Egress | Notes |
|---|---|---|---|---|---|
| Free | $0 | Unlimited | 500 MB per project | 5 GB | 2 active projects; paused after 1 week of inactivity |
| Pro | from $25/month | Unlimited | 8 GB per project included | 250 GB | No inactivity pausing; compute billed per project |
Common Supabase API errors
Every error body has the same four fields: code (a Postgres SQLSTATE or a PGRST code), message, details and hint. The hint often contains the fix.
| Status / code | Usual cause | Fix |
|---|---|---|
401 "No API key found in request" |
Missing apikey header |
Add the header (or ?apikey= for a quick browser test) |
401 "Invalid API key" |
Wrong project, typo, or a deactivated legacy key | Copy the key again from Settings > API Keys |
401 with a secret key from a browser |
Secret keys are blocked in browsers | Use the publishable key on the client |
401 PGRST301 / PGRST303 |
User JWT invalid or expired | Refresh the session and resend |
401/403 code 42501 "permission denied for table" |
Role has no grant on the table | grant select ... to anon, authenticated (the hint names it) |
42501 "new row violates row-level security policy" |
No insert/update policy matches | Add a policy, or send the user's access token |
200 with [] |
RLS is on and no select policy matches | Add a select policy for that role |
"Access to schema is forbidden" on /rest/v1/ |
OpenAPI spec requested with the publishable/anon key | Use a secret key server-side or the Management API |
404 PGRST205 |
Table not found (wrong name, or schema not exposed) | Check the name and Integrations > Data API exposed schemas |
404 PGRST202 |
Function not found or argument names differ | Match argument names; notify pgrst, 'reload schema' |
400 PGRST100 |
Bad query string syntax | Check column=operator.value formatting and URL-encode values |
400 PGRST102 |
Empty or malformed JSON body | Validate the body with a JSON formatter |
400 PGRST200 |
No relationship found for an embed | Add the foreign key; reload the schema cache |
300 PGRST201 |
Ambiguous embed (several foreign keys) | Name the key: table!fk_column(*) |
406 PGRST116 |
.single() got zero or several rows |
Use .maybeSingle() or tighten the filter |
416 PGRST103 |
Range or offset out of bounds | Stop paginating at the Content-Range total |
409 code 23505 |
Unique constraint violation | Upsert, or check for the row first |
409 code 23503 |
Foreign key violation | Insert the parent row first |
57014 |
Statement timeout | Add an index or raise the role's timeout |
429 (Auth) |
Auth rate limit | Back off; raise the limit or add custom SMTP |
The full list is in the PostgREST error reference. For a general walkthrough of 4xx debugging, see our guide to 400 Bad Request in REST APIs, and look up any status in the HTTP status codes reference.
Supabase vs REST API you build yourself, curl or Postman
Searches like "Supabase vs REST API", "curl vs Supabase" and "Postman vs Supabase" mix up different layers. Supabase is a REST API (plus a database and auth). curl and Postman are HTTP clients you use to call it. So the real comparisons are these two:
REST API vs Supabase: the generated Data API or your own server?
| Supabase Data API | Your own REST server (Express, FastAPI, etc.) | Supabase Edge Functions | |
|---|---|---|---|
| Code to write | None for CRUD | Every route | Only custom routes |
| Authorization | RLS policies in SQL | Your middleware | Your code, with supabase-js inside |
| Joins, filters, pagination | Built in (URL syntax) | You implement them | You implement them |
| Custom business logic | Postgres functions (RPC) | Anything | Anything (TypeScript) |
| Third-party API calls, webhooks | Not directly | Yes | Yes |
| Hosting | Included | You run it | Included |
| Public API for your own customers with your own keys | Awkward (keys are per project) | Natural fit | Good fit |
Most apps use a mix: the Data API for plain reads and writes guarded by RLS, RPC for multi-step logic, and Edge Functions or a server for anything that holds third-party secrets.
Supabase vs curl, Postman or Insomnia: just clients. Use curl for quick tests and scripts, and Postman or Insomnia when you want saved requests and environments. In Postman, create an environment with SB_URL and SB_KEY, add apikey: {{SB_KEY}} as a collection header, and import the curl commands from this page (Import > Raw text). Importing the auto-generated OpenAPI spec from /rest/v1/ now requires a secret key, so fetch it once on your machine with the secret key or from the Management API (GET https://api.supabase.com/v1/projects/{ref}/database/openapi) and import the file. Don't save a secret key in a shared workspace. For more client options, browse API tools on DevHunt.
security definer.Limitations worth knowing
- The Data API exposes tables, views and functions, not arbitrary SQL. For complex logic, write a Postgres function and call it at
/rest/v1/rpc/<function>. - Anything the publishable key can reach is public to anyone who opens your site. Your RLS policies are the only security boundary.
- Large result sets are capped by the project's max rows setting (1,000 by default). Page through them with
limitandoffsetor aRangeheader. - Each request is one SQL statement. A multi-step write that must succeed or fail together belongs in a function.
- Only exposed schemas are reachable. Keep internal tables in a schema that is not exposed (Supabase suggests a dedicated
apischema for what you do expose), and turn the whole Data API off in Integrations > Data API if you only connect to Postgres directly. - The OpenAPI description at
/rest/v1/is no longer public: it needs a secret key since April 8, 2026. GETandHEADcan be served by read replicas; rate-limiting functions apply only to writes.
If you are still choosing a backend, compare it on the Supabase listing on DevHunt, browse database tools or free Firebase alternatives. For other APIs you can try without a credit card, see free APIs for developers. If you are documenting an API of your own on top of Supabase, our REST API documentation best practices apply.
FAQ
What is the Supabase API URL?
It is https://<project-ref>.supabase.co, and the REST endpoint for a table is https://<project-ref>.supabase.co/rest/v1/<table>. You'll find it in the Connect dialog of your project or under Integrations > Data API.
How do I get my Supabase URL and anon key?
Click Connect in your project dashboard to see the URL and the publishable key, which replaces the anon key. The legacy anon key is still listed under Settings > API Keys until you deactivate it, but new code should use the sb_publishable_ key. That is how to get Supabase URL and anon key values for any client: copy both from the same Connect dialog.
Is the Supabase publishable key safe to expose?
Yes, it is meant to be public. It only works as the anon or authenticated role, so what it can do depends entirely on your grants and RLS policies. The secret key and the service_role key must stay on the server.
Is the Supabase API the same as PostgREST?
The Data API is PostgREST (v14 since December 2025) running behind Supabase's API gateway. The gateway adds key checks and connects PostgREST to Supabase Auth. The query syntax, headers like Prefer, and error codes are standard PostgREST.
What is a Supabase PAT used for?
A personal access token authenticates you, the account owner, to the Management API and the CLI, for example to create projects or run migrations. It cannot read table data through the Data API, and it should never go into application code.
What is a simple Supabase REST API example?
curl "https://<project-ref>.supabase.co/rest/v1/leaderboard?select=player,score&order=score.desc&limit=10" -H "apikey: sb_publishable_..." returns the top 10 rows as JSON. It works once the table has a select grant for anon and a select policy that allows the rows.
Where are the official Supabase REST API docs?
The guides are at supabase.com/docs/guides/api, and the dashboard generates per-table docs with curl and JavaScript snippets for your own schema (the API docs section of your project). For the full query syntax, use the PostgREST v14 reference.
Should I send the API key in apikey or Authorization: Bearer?
Send the publishable or secret key in apikey. Use Authorization: Bearer only for a signed-in user's access token from Supabase Auth. The new sb_ keys are not JWTs, so putting them in Authorization doesn't make the request authenticated.
Does the Supabase REST API have rate limits?
Not for the Data API itself: Supabase pricing lists unlimited API requests on every plan, and the practical limits are database size, compute, egress and statement timeouts (3 s for anon, 8 s for authenticated by default). The Auth endpoints are rate limited, for example 150 token requests per IP per 5 minutes and 2 emails per hour with the built-in email sender.
How do I get more than 1,000 rows from the Supabase API?
Paginate with limit and offset, a Range header, or .range(from, to) in supabase-js, and always set order. You can raise Max rows in the Data API settings, but Supabase recommends keeping it low so a single request can't pull a huge payload.
Why does my Supabase API call return an empty array?
The request worked, but RLS filtered out every row for that role. Either no select policy matches anon or authenticated, or the policy checks auth.uid() and you didn't send a user token. Check the policy and the Authorization header.
How do I enable the Supabase Data API?
The Supabase Enable Data API switch is under Integrations > Data API in the dashboard. Make sure it is on and your schema (usually public) is in the exposed schemas list. Then grant the roles access to each table: new tables are no longer exposed automatically since October 30, 2026.
Supabase vs curl: which should I use?
They aren't alternatives. curl vs Supabase is like a browser vs a website: curl is an HTTP client, and Supabase is the API it calls. Use curl to test the Data API, and supabase-js (or fetch) in your app.