One Server

Docs · Reference

Project API

Each project has its own address, and supabase-js connects to it unchanged. Every route below is a list held to the server’s router by a test.

Address and keys

A project lives at https://<ref>.<zone>. Send a key with every request in the apikey header (or Authorization: Bearer). The publishable key is for browsers and follows the access rules (RLS). The secret key bypasses every rule, so use it only on a server. A signed-in user’s token in Authorization: Bearer runs the request as that user. The keys are on the API keys screen.

curl "https://abcdefghijklmnopqrst.end.blackall.ai/rest/v1/notes?select=id,title" \
  -H "apikey: <publishable key>"

supabase-js compatibility

Paths, query strings, headers and responses are Supabase’s, so @supabase/supabase-js works as it is: change only the address and the key. The supabase-js column of each table is the call that reaches that route.

import { createClient } from '@supabase/supabase-js'

const supabase = createClient('https://abcdefghijklmnopqrst.end.blackall.ai', '<publishable key>')

Data (/rest/v1)

The PostgREST query language. The table name is the path and column names are query parameters. Every request runs with the caller’s role and the access rules (RLS).

MethodPathWhat it doessupabase-js
GET/rest/v1/{table}Read rowsfrom('t').select()
HEAD/rest/v1/{table}Count onlyselect('*', { head: true, count: 'exact' })
POST/rest/v1/{table}Insert rows (upsert with Prefer: resolution=…)insert() / upsert()
PUT/rest/v1/{table}Upsert one row
PATCH/rest/v1/{table}Update the rows that match the filtersupdate()
DELETE/rest/v1/{table}Delete the rows that match the filtersdelete()
GET/rest/v1/rpc/{function}Call a function (read)rpc('f', args, { get: true })
POST/rest/v1/rpc/{function}Call a functionrpc('f', args)
POST/graphql/v1GraphQL (pg_graphql, once turned on under Database → Extensions; same keys and access rules as /rest/v1)fetch / Apollo / urql

Query parameters

NameDescription
selectColumns to return. Aliases (label:title), casts (id::text), JSON paths (data->>name), rows joined through a foreign key (*,author(name), !inner)
orderOrdering, created_at.desc.nullslast; several with commas
limitHow many rows, at most 1000
offsetHow many rows to skip
on_conflictThe columns an upsert matches on
columnsThe columns an insert writes
<column>A column=operator.value filter
or · andor=(a.eq.1,b.gt.2), and=(…), nested, and not.or=(…)

Filter operators

Only these operators are accepted. An unknown one is refused, not silently ignored. Prefix any of them with not. to negate it.

OperatorSQL
eq=
neq<>
gt>
gte>=
lt<
lte<=
likelike
ilikeilike
match~
imatch~*
cs@>
cd<@
ov&&
sl<<
sr>>
nxr&<
nxl&>
adj-|-
isdistinctis distinct from
isis null | not null | true | false | unknown
inin (…)
fts@@ to_tsquery()
plfts@@ plainto_tsquery()
phfts@@ phraseto_tsquery()
wfts@@ websearch_to_tsquery()

Headers

NameDescription
Preferreturn=minimal|representation|headers-only, count=exact|planned|estimated, resolution=merge-duplicates|ignore-duplicates, missing=default
AcceptAccept: application/vnd.pgrst.object+json returns one row as an object (.single()); zero or several rows is a 406
Accept-ProfileAccept-Profile and Content-Profile accept only public
Content-RangeThe total (count=) comes back in the Content-Range header

Calling functions (rpc)

/rest/v1/rpc/<function> runs a SQL function in the project schema with the caller’s role. With POST the body is the arguments; with GET, query parameters named like an argument are arguments and the rest filter the result.

const { data, error } = await supabase
  .from('notes')
  .select('id, title, author(name)')
  .eq('done', false)
  .order('created_at', { ascending: false })
  .range(0, 19)

await supabase.from('notes').insert({ title: 'Hello' }).select().single()
await supabase.rpc('search_notes', { query: 'hello' })
curl "https://abcdefghijklmnopqrst.end.blackall.ai/rest/v1/notes?select=id,title&done=eq.false&order=created_at.desc&limit=20" \
  -H "apikey: <publishable key>" \
  -H "Prefer: count=exact"

Not supported

  • Aggregate (count(), sum() and so on) and spread (...rel(*)) selects
  • Schemas other than public
  • Reading more than 1000 rows at once (limit is capped at 1000)

Auth (/auth/v1)

GoTrue’s paths and responses. Email and password, magic links, anonymous sign-in, social sign-in (with PKCE), TOTP two-factor authentication and user management with the secret key.

MethodPathWhat it doessupabase-js
POST/auth/v1/signupSign up; with no email and password, an anonymous sign-inauth.signUp() / auth.signInAnonymously()
POST/auth/v1/token?grant_type=passwordSign in with email and passwordauth.signInWithPassword()
POST/auth/v1/token?grant_type=refresh_tokenRefresh a sessionauth.refreshSession()
POST/auth/v1/token?grant_type=pkceExchange a PKCE code for a sessionauth.exchangeCodeForSession()
POST/auth/v1/token?grant_type=web3Sign in with a Sign-In with Ethereum message the wallet signedauth.signInWithWeb3()
POST/auth/v1/token?grant_type=id_tokenSign in with a Google or Apple ID tokenauth.signInWithIdToken()
POST/auth/v1/otpSend a magic link or a text-message codeauth.signInWithOtp({ email } | { phone })
POST/auth/v1/verifyVerify a mailed token or a text-message code (signup, magiclink, recovery, email_change, sms, phone_change)auth.verifyOtp()
GET/auth/v1/verifyA mailed link opened in a browser; redirects to a registered address with the session
POST/auth/v1/resendResend the confirmation mailauth.resend()
POST/auth/v1/recoverSend a password reset mailauth.resetPasswordForEmail()
POST/auth/v1/resetSet a new password with a reset token (used by the page shown when there is nowhere to redirect)
GET/auth/v1/userThe signed-in user and their factorsauth.getUser()
PUT/auth/v1/userChange email, password or metadataauth.updateUser()
GET/auth/v1/reauthenticateMail a code to confirm a password changeauth.reauthenticate()
POST/auth/v1/logoutSign out (scope=local|others|global)auth.signOut({ scope })
GET/auth/v1/settingsWhich sign-in methods are on
GET/auth/v1/authorizeStart a social sign-inauth.signInWithOAuth()
GET/auth/v1/callbackWhere the provider returns (POST for Apple)
DELETE/auth/v1/user/identities/{id}Unlink a social identityauth.unlinkIdentity()
POST/auth/v1/factorsEnroll a TOTP factorauth.mfa.enroll({ factorType: 'totp' })
POST/auth/v1/factors/{id}/challengeStart a challengeauth.mfa.challenge()
POST/auth/v1/factors/{id}/verifyVerify a code (an aal2 session)auth.mfa.verify()
DELETE/auth/v1/factors/{id}Remove a factorauth.mfa.unenroll()
GET/auth/v1/admin/usersList users (secret key, per_page at most 1000)auth.admin.listUsers({ page, perPage })
POST/auth/v1/admin/usersCreate a user (secret key)auth.admin.createUser()
POST/auth/v1/ssoStart a SAML SSO sign-in (returns the IdP address; PKCE works)auth.signInWithSSO({ domain } | { providerId })
GET/auth/v1/sso/saml/metadataThis project's SP metadata (register it with the IdP)
POST/auth/v1/sso/saml/acsWhere the IdP posts its SAML response (signature, audience and replay checked)
GET/auth/v1/admin/sso/providersList SSO providers (secret key)auth.admin.sso.listProviders()
POST/auth/v1/admin/sso/providersAdd an SSO provider (metadata_url or metadata_xml, domains)auth.admin.sso.createProvider()
GET/auth/v1/admin/sso/providers/{id}One SSO providerauth.admin.sso.getProvider()
PUT/auth/v1/admin/sso/providers/{id}Change an SSO provider (re-read metadata after a certificate rotation)auth.admin.sso.updateProvider()
DELETE/auth/v1/admin/sso/providers/{id}Delete an SSO providerauth.admin.sso.deleteProvider()
GET/auth/v1/admin/users/{id}One user (secret key)auth.admin.getUserById()
PUT/auth/v1/admin/users/{id}Update a user (secret key)auth.admin.updateUserById()
DELETE/auth/v1/admin/users/{id}Delete a user (secret key)auth.admin.deleteUser()
POST/auth/v1/inviteSend an invitation email, through the project's SMTP and template (secret key)auth.admin.inviteUserByEmail()
POST/auth/v1/admin/generate_linkMake a mail link: signup, invite, magiclink, recovery (secret key)auth.admin.generateLink()
GET/auth/v1/.well-known/jwks.jsonPublic keys to verify user tokens (ES256)
GET/auth/v1/.well-known/openid-configurationOpenID configuration (issuer, jwks_uri)
GET/auth/v1/.well-known/oauth-authorization-serverOAuth authorization server metadata (RFC 8414, for MCP clients)
GET/.well-known/oauth-authorization-server/auth/v1The same metadata, at the address RFC 8414 defines

Token grants (grant_type): password, refresh_token, pkce, id_token (an ID token from Google or Apple).

Social sign-in providers: google, kakao, naver, apple, github, gitlab, microsoft, facebook, line, discord, x, bitbucket, figma, linkedin_oidc, notion, twitch, slack_oidc, spotify, zoom, azure, keycloak, workos, twitter, slack. Configure them in the console or with the management API’s set_auth_provider.

await supabase.auth.signUp({ email: '[email protected]', password: 'a-long-passphrase' })
await supabase.auth.signInWithPassword({ email: '[email protected]', password: 'a-long-passphrase' })
await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: 'https://app.example.com' } })
const { data: { user } } = await supabase.auth.getUser()

Not supported

  • Second factors other than TOTP (WebAuthn, phone)
  • Reauthentication (reauthenticate) and linking an identity (linkIdentity); only unlinking is supported
  • auth.admin.inviteUserByEmail; use generateLink({ type: 'invite' }) instead

Storage (/storage/v1)

Supabase Storage’s paths. Files are recorded in a table of the project, so the access rules on that table decide who may read and write each file.

A file is accepted up to 50MB by default (a bucket can set a lower limit). A signed URL lasts at most 7 days, and a signed upload URL two hours. Bucket names use lowercase letters, digits, ., _ and -.

MethodPathWhat it doessupabase-js
GET/storage/v1/bucketList bucketsstorage.listBuckets()
POST/storage/v1/bucketCreate a bucket (public, file_size_limit, allowed_mime_types)storage.createBucket()
GET/storage/v1/bucket/{bucket}One bucketstorage.getBucket()
PUT/storage/v1/bucket/{bucket}Change a bucket’s settingsstorage.updateBucket()
DELETE/storage/v1/bucket/{bucket}Delete an empty bucketstorage.deleteBucket()
POST/storage/v1/bucket/{bucket}/emptyEmpty a bucketstorage.emptyBucket()
POST/storage/v1/object/{bucket}/{path}Upload (x-upsert)from('b').upload()
PUT/storage/v1/object/{bucket}/{path}Replacefrom('b').update()
GET/storage/v1/object/{bucket}/{path}Download as the caller (HEAD too)from('b').download()
DELETE/storage/v1/object/{bucket}/{path}Delete one file
DELETE/storage/v1/object/{bucket}Delete several files (prefixes, at most 1000)from('b').remove([paths])
GET/storage/v1/object/public/{bucket}/{path}A file in a public bucketfrom('b').getPublicUrl()
GET/storage/v1/object/authenticated/{bucket}/{path}Download as the signed-in user
POST/storage/v1/object/list/{bucket}List one folder level (limit at most 1000)from('b').list(prefix, { limit, offset, sortBy, search })
GET/storage/v1/object/info/{bucket}/{path}File detailsfrom('b').info()
POST/storage/v1/object/moveMovefrom('b').move()
POST/storage/v1/object/copyCopyfrom('b').copy()
POST/storage/v1/object/sign/{bucket}/{path}Make a signed URLfrom('b').createSignedUrl(path, expiresIn, { transform })
POST/storage/v1/object/sign/{bucket}Several signed URLs (at most 100)from('b').createSignedUrls(paths, expiresIn)
GET/storage/v1/object/sign/{bucket}/{path}Download with a signed URL (?token=)
POST/storage/v1/object/upload/sign/{bucket}/{path}Make a signed upload URLfrom('b').createSignedUploadUrl()
PUT/storage/v1/object/upload/sign/{bucket}/{path}Upload with a signed URL (?token=)from('b').uploadToSignedUrl()
GET/storage/v1/render/image/public/{bucket}/{path}Transform a public imagefrom('b').getPublicUrl(path, { transform })
GET/storage/v1/render/image/authenticated/{bucket}/{path}Transform an image as the signed-in userfrom('b').download(path, { transform })
GET/storage/v1/render/image/sign/{bucket}/{path}Transform an image with a signed URL
POST/storage/v1/upload/resumableStart a resumable upload (TUS 1.0, Upload-Length, bucketName and objectName in Upload-Metadata)tus-js-client / Uppy
PATCH/storage/v1/upload/resumable/{id}Send the next part (Upload-Offset, application/offset+octet-stream)
HEAD/storage/v1/upload/resumable/{id}How much has been received so far
DELETE/storage/v1/upload/resumable/{id}Cancel a resumable upload
GET/storage/v1/s3/{bucket}/{key}S3-compatible API (SigV4 with an S3 key made in the console, path-style, default region ap-northeast-2)AWS SDK / rclone

Upload the file as the body (with its Content-Type) or as multipart/form-data. POST refuses an existing name unless x-upsert: true; PUT replaces.

Image transformation

Pass the options as query parameters to /render/image/{public|authenticated|sign}/<bucket>/<path>. A server without an image transformer serves the original and says so in the x-blackwall-transform header.

NameDescription
width1 to 2500 pixels
height1 to 2500 pixels
resizecover (default), contain, fill
quality20 to 100 (default 80)
formatorigin (default), webp, avif, png, jpeg
const bucket = supabase.storage.from('avatars')
await bucket.upload('me.png', file, { upsert: true })
const { data } = bucket.getPublicUrl('me.png', { transform: { width: 200, height: 200 } })
const { data: signed } = await bucket.createSignedUrl('me.png', 3600)

Not supported

  • A per-file cache setting (the cacheControl option) is not stored

Realtime (/realtime/v1)

wss://<ref>.<zone>/realtime/v1/websocket?apikey=<key>&vsn=1.0.0 speaks Phoenix channels, which is what supabase-js uses. vsn=2.0.0 uses array messages and binary broadcasts. /realtime/v1 (without websocket) is a plain JSON protocol for the same features.

A channel’s topic is realtime:<name>. Clients that join the same name share broadcasts and presence.

Events a client sends

EventDescription
phx_joinJoin a channel: config.broadcast.self/ack, config.presence.key, config.postgres_changes, access_token
phx_leaveLeave a channel
heartbeatSent on the phoenix topic; the server answers at once with phx_reply. Used to check the connection is alive
access_tokenSwitch to a signed-in user’s new token
broadcastSend to the others in the channel (never touches the database)
presencetrack to publish your state, untrack to remove it

Events the server sends

EventDescription
phx_replyThe answer to a message (ok or error)
presence_stateWho is already there when you join
presence_diffWho joined and who left
broadcastSend to the others in the channel (never touches the database)
postgres_changesA row changed in a table (INSERT, UPDATE, DELETE)
phx_closeThe channel closed
systemNotices such as a refused token switch

postgres_changes arrive only from tables with realtime turned on in the console or with set_realtime. Each row is checked against the receiver’s access rules before it is sent. Filters are column=eq|neq|lt|lte|gt|gte|in.value.

supabase
  .channel('room-1')
  .on('broadcast', { event: 'cursor' }, ({ payload }) => console.log(payload))
  .on('presence', { event: 'sync' }, () => console.log('online'))
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notes', filter: 'done=eq.false' }, (change) => console.log(change.new))
  .subscribe()
wss://abcdefghijklmnopqrst.end.blackall.ai/realtime/v1/websocket?apikey=<publishable key>&vsn=1.0.0

{"topic":"realtime:room-1","event":"phx_join","payload":{"config":{"broadcast":{"self":false},"presence":{"key":"me"},"postgres_changes":[{"event":"*","schema":"public","table":"notes"}]}},"ref":"1","join_ref":"1"}
{"topic":"realtime:room-1","event":"broadcast","payload":{"type":"broadcast","event":"cursor","payload":{"x":1}},"ref":"2","join_ref":"1"}
{"topic":"phoenix","event":"heartbeat","payload":{},"ref":"3"}

Not supported

  • Private channels (config.private: true)
  • The previous values of an UPDATE (old is empty)

Functions (/functions/v1)

/functions/v1/<name> calls a deployed function. Every HTTP method is passed through, and the body may be up to 2MB. A private function (the default) needs a key of this project: the publishable key (anon), a signed-in user's token (authenticated) or the secret key (service_role). A function deployed as public can be called with no key. The function receives the verified claims, never the token. Names start with a lowercase letter and use lowercase letters, digits and -.

MethodPathWhat it doessupabase-js
*/functions/v1/{name}Call a function (any method)functions.invoke('name', { body })
const { data, error } = await supabase.functions.invoke('hello', { body: { name: 'world' } })

curl -X POST "https://abcdefghijklmnopqrst.end.blackall.ai/functions/v1/hello" \
  -H "Authorization: Bearer <user token or secret key>" \
  -H "content-type: application/json" \
  -d '{"name":"world"}'

Other routes

MethodPathWhat it doessupabase-js
POST/kv/v1KV commands (Upstash style ["SET", "k", "v"]; also /kv/v1/pipeline, /kv/v1/keys, /kv/v1/usage; secret key)@upstash/redis
*/kv/v1/{key}Read, write or delete one KV key (GET, PUT, DELETE, /incr, /expire)
GET/config/v1The whole edge config (secret key)@vercel/edge-config
GET/config/v1/{key}One edge config valueget('key')
POST/ai/v1/{endpoint}AI gateway, OpenAI-compatible (chat/completions, embeddings, images/generations, audio/speech, audio/transcriptions, models)openai SDK (baseURL)
GET/healthWhether the project answers ({ project, ok })
GET/realtime/v1/websocketPhoenix channels (websocket)channel('name').subscribe()
GET/realtime/v1The plain JSON protocol (websocket)

Errors

Errors have the shape each Supabase client reads on that route. Data errors are PostgREST’s: code (PGRST…), message, details, hint. Auth errors are GoTrue’s: code, error_code, msg (and OAuth’s error and error_description from the token endpoint). Storage errors are storage-js’s: statusCode, error, message. Messages follow Accept-Language (Korean, English, Japanese).

{ "code": "PGRST116", "message": "…", "hint": null, "details": "…" }