> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# REST reference

> Every /rest/v1 rule, limit and error code for a project's tables, views and functions.

Every project with the Data API switched on serves its database's tables, views
and functions over HTTP at `https://<slug>.<project domain>/rest/v1`. This page
is the reference for that endpoint: what it accepts, what it refuses, and every
error it answers with. It is current as of 2026-10-27.

**Before you start**

* The project has a database and the `data_api` module switched on: in the
  console (Data API, Turn on), or `PATCH /projects/<project id>` with the
  project's whole `services` list including `data_api`. Turning it off answers
  `service_not_enabled` within about 30 seconds and keeps your table exposure
  and settings for when you turn it back on.
* You have a project API key: a publishable key (`dtn_pk_...`) or a secret key
  (`dtn_sk_...`).
* The table you call is exposed to the Data API (see [Exposing tables](#exposing-tables)).
* The project's Data API URL is the `url` of `GET /databases/<database id>/data_api`
  on the management API.

## Keys and roles

| Key | Runs as | Use it |
| - | - | - |
| Publishable (`dtn_pk_...`) | the `anon` role: sees only what you granted `anon`, filtered by your row-level security policies | in browsers and apps |
| Secret (`dtn_sk_...`) | the database owner: every table, row-level security not applied unless the table forces it | on servers only; never ship it to a browser |

* Send the key in the `apikey` header. A key in the query string (`?apikey=`)
  is refused with `api_key_in_url`.
* `Authorization: Bearer <the same key>` is accepted and ignored. Any other
  bearer token is refused with `user_token_invalid` (401, with a
  `WWW-Authenticate: Bearer error="invalid_token"` challenge): the Data API
  takes project keys only.
* Every answer carries an `x-request-id` header. Quote it when you contact
  support.

## Exposing tables

A new table is not reachable through the Data API. A publishable call to it
answers 403 `data_access_denied`; a secret call reads it as the owner.

Expose a table with a schema change on the management API:

```http theme={null}
POST /databases/<database id>/schema_changes
Content-Type: application/json

{
  "changes": [
    {
      "kind": "set_table_exposure",
      "table": { "schema": "public", "name": "todos" },
      "exposure": "read"
    }
  ]
}
```

| `exposure` | What `anon` and `authenticated` may do |
| - | - |
| `none` | nothing (every grant removed) |
| `read` | `SELECT` |
| `read_write` | `SELECT`, `INSERT`, `UPDATE`, `DELETE`, and use of the table's own sequences |

* Exposing a table also turns its row-level security on. With no policy, an
  exposed table answers no rows to the publishable key: `200 []`. Add a policy
  for `anon` to return rows, for example
  `create policy "read open todos" on public.todos for select to anon using (not done);`
* `create_table` changes take the same optional `exposure`.
* Functions: `set_function_exposure` with the function's name, its argument
  types as `signature`, and `exposed: true` or `false`. A function is callable
  only in an exposed schema.
* The table list and the table detail on the management API report each
  table's `exposure` and `policyCount`;
  `custom` means grants that match none of the three, made over a direct
  connection. Setting an exposure replaces them.
* Every exposure change is recorded in the database's History like any other
  schema change.

## Reading

| Feature | Grammar |
| - | - |
| Tables and views | `GET` or `HEAD /rest/v1/<table>` |
| Columns | `select=id,title`; rename `select=label:title`; cast `select=id::text` |
| Related rows | `select=id,author(name)`; inner join `author!inner(name)`; left join `author!left(name)`; name the relationship when two match |
| Filters | `<column>=<operator>.<value>` with `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, `is`; negate with `not.`; combine with `or=(...)` and `and=(...)`; `(any)` and `(all)` modifiers |
| Order | `order=title.asc,id.desc`, with `nullsfirst` or `nullslast` |
| Paging | `limit` and `offset`, or the `Range` and `Range-Unit` headers; a partial answer is `206` with `Content-Range`, a range past the end `416` |
| Totals | `Prefer: count=exact`, `count=planned` or `count=estimated`; the total is the number after `/` in `Content-Range` |
| One object | `Accept: application/vnd.pgrst.object+json` answers a single JSON object, or `406` when the result is not exactly one row |

Lists are plain JSON arrays paged by offset and range. They are not the
`{ data, nextCursor }` envelope the management API uses.

## Writing

| Feature | Grammar |
| - | - |
| Insert | `POST /rest/v1/<table>` with one JSON object or an array; `columns=` limits the columns read from the body; `Prefer: missing=default` fills absent columns with their defaults |
| Upsert | `POST` with `Prefer: resolution=merge-duplicates` or `resolution=ignore-duplicates`, and `on_conflict=<columns>`; or `PUT /rest/v1/<table>?<primary key filters>` |
| Update | `PATCH /rest/v1/<table>?<filter>` |
| Delete | `DELETE /rest/v1/<table>?<filter>` |
| What comes back | `Prefer: return=minimal` (nothing), `return=headers-only` (`Location`), `return=representation` (the rows) |

**Updates and deletes need a filter.** A `PATCH` or `DELETE` on a table whose
query holds only `select`, `order`, `limit`, `offset`, `columns` or
`on_conflict` is refused with `filter_required` before anything runs. `limit`
alone is not a filter.

**Updates and deletes are bounded.** Each runs with `handling=strict` and a
`max-affected` cap, so a change that would touch more rows than the cap
changes nothing and answers `max_affected_exceeded`. The cap is the project's
limit (1,000 rows by default), or your own lower `Prefer: max-affected=<n>`.

## Functions

* `POST /rest/v1/rpc/<function>` with the arguments as one JSON object.
* `GET /rest/v1/rpc/<function>?<argument>=<value>` for functions declared
  `stable` or `immutable`.
* A `RAISE` in your function answers `data_function_raised` with your
  message as `detail`. A status your function sets is answered with that
  status and the code in [A status your function sets](#a-status-your-function-sets).

## Schemas and the API description

* The project's exposed schemas default to `public`. Change the list with
  `PATCH /databases/<database id>/data_api` (`exposedSchemas`, at most 20).
  History, the system catalogs and schemas starting `pg_` cannot be exposed.
* Pick a schema per request with `Accept-Profile` (reads) or
  `Content-Profile` (writes).
* `GET /rest/v1/` with a secret key answers an OpenAPI 2.0 description of what
  the owner may reach. A publishable key is refused with
  `api_key_kind_not_allowed`.

## Preferences

The `Prefer` header (RFC 7240) is read as a comma-separated list.

| Preference | Handling |
| - | - |
| `tx=` | dropped on every request: a request cannot ask to be rolled back |
| `handling`, `max-affected` on `PATCH` and `DELETE` | replaced by `handling=strict` and the bounded `max-affected` (see [Writing](#writing)) |
| an unknown preference under `handling=strict` | refused with `data_request_invalid` |
| every other preference | passed on unchanged, in order |

## Limits

| Limit | Default | Notes |
| - | - | - |
| Rows per read | 1,000 | Set lower for the project with `maxRows` on `PATCH /databases/<database id>/data_api`; it cannot exceed the ceiling the view reports as `maxRowsCeiling`. A read never returns more rows; use paging and `Prefer: count=exact` for the total |
| Rows per update or delete | 1,000 | `maxAffectedRows` in the view |
| Request body | 1 MiB | `maxRequestBytes` in the view; a larger body is refused with `request_body_too_large` before it is read |
| Statement timeout, publishable key (`anon`) | 3 s | Set by your operator for the database server; a change reaches the project's Data API the next time it starts, after it was stopped for being idle or after its settings change |
| Statement timeout, signed-in users (`authenticated`) | 8 s | as above |
| Request time | 25 s | then `service_timeout` |

The project's Data API stops after 10 minutes without a request. The first
request after that waits while it starts; if the start takes more than 2
seconds it answers `unavailable` with `Retry-After: 1`.

## Idempotency

* Send `Idempotency-Key: <1 to 255 of A-Z a-z 0-9 _ ->` on `POST`, `PATCH` or
  `DELETE` to make a retry safe. `GET`, `HEAD` and `PUT` are idempotent
  already, so the header is ignored on them.
* A key is scoped to the project and the API key that sent it. The same key
  with the same request (method, path, query, `Content-Type`, `Prefer` and
  body) replays the first answer, for 24 hours.
* The same key with a different request: 422 `idempotency_key_reused`. The
  same key while the first request still runs: 409
  `idempotency_request_in_progress`.
* Answers with a 2xx or 4xx status are kept for replay. A 5xx answer is not,
  so a retry under the same key runs again.
* An answer body larger than 1 MiB is not kept: its replay has the same status
  and headers and an empty body.

## CORS

* Browsers may call the Data API only from origins on the project's list:
  `corsOrigins` on `PATCH /databases/<database id>/data_api`, each exactly
  `scheme://host[:port]`, at most 20. The list is empty by default, so no
  browser origin is allowed until you add one. `*` allows every origin and
  must be the only entry.
* A preflight from a listed origin answers `204` with
  `Access-Control-Allow-Methods: GET, HEAD, POST, PUT, PATCH, DELETE`, the
  allowed request headers (including `apikey`, `authorization` and
  `idempotency-key`) and `Access-Control-Max-Age: 600`. From any other origin:
  403 `cors_origin_not_allowed`.
* Answers to a listed origin echo it in `Access-Control-Allow-Origin` with
  `Vary: Origin`, and expose `Content-Range`, `Content-Location`, `Location`,
  `Preference-Applied`, `x-request-id` and `Retry-After`.
* Use the publishable key in browsers. A secret key works from a browser too,
  so never ship it in one.

## Schema changes and reloads

The Data API reads your tables and functions once and keeps that picture. A
change made through the management API, the console or the management MCP is
picked up within about 2 seconds, with no action from you.

A change made over a direct database connection is not. After one, ask for a
reload:

* `POST /databases/<database id>/data_api/schema_reloads` (answers `202`), or
* `databases.dataApi.reloadSchema(<database id>)` in the SDK, or
* the project MCP's `data_api_run_schema_reload` tool.

## Paused databases

| Database | Answer | What to do |
| - | - | - |
| Paused for being idle | 503 `database_resuming` with `Retry-After: 3`; the request starts the database | retry after `Retry-After`; `@supabase/postgrest-js` retries reads on 503 by itself, up to 3 times |
| Paused by its owner, the operator, or for its storage limit | 409 `database_paused` | resume it; a retry does not |
| Being created, restored or deleted | 409 `database_not_ready` | retry once it is ready |

## Errors

Every refusal is an RFC 9457 problem, `Content-Type: application/problem+json`:

```json theme={null}
{
  "type": "https://duraton.dev/problems/row_conflict",
  "title": "row_conflict",
  "status": 409,
  "detail": "A row with the same unique value already exists, or a referenced row is missing.",
  "code": "row_conflict",
  "postgres": { "code": "23505" }
}
```

* `code` is stable: it is never renamed or reused. Branch on it, not on
  `detail`.
* `postgres` is present when the database itself refused the request and the
  status is below 500. It carries the database's error code (SQLSTATE) as
  `code`. A secret key also gets `message`, `details` and `hint`; a publishable
  key gets `code` only.
* With `@supabase/postgrest-js`, `error.code` is this `code`. Code written for
  a client that compared `error.code` with a SQLSTATE such as `"23505"` reads
  `error.postgres.code` instead.

### Error codes

| Code | Status | Detail | What it means |
| - | - | - | - |
| `api_key_required` | 401 | This request needs an API key. | No API key was sent. Send the project's publishable or secret key in the `apikey` header. |
| `api_key_invalid` | 401 | The API key is not valid. | The `apikey` header holds no key of this project. Check the key and the project host. |
| `api_key_revoked` | 401 | The API key has been revoked. Use an active key. | The key was revoked. Issue a new key and send that one. |
| `api_key_kind_not_allowed` | 403 | This kind of API key cannot be used here. | This kind of key may not make this request: the API description at `/rest/v1/` needs a secret key. |
| `api_key_in_url` | 400 | Send the API key in the apikey header, never in the URL. | The key was sent in the query string, where logs and browser history keep it. Send it in the `apikey` header only. |
| `user_token_invalid` | 401 | The user token is not valid. | An `Authorization: Bearer` value that is not one of the project's keys was sent. The Data API takes project keys only; send the key in `apikey` and no other bearer token. |
| `not_found` | 404 | The requested resource was not found. | No project answers at this host, or the path is not under `/rest/v1`. |
| `bad_request` | 400 | The request could not be understood. | The path holds an encoded `/` or `\` (`%2F`, `%5C`), which is refused; also the code of a 4xx status your function sets that has no code of its own. |
| `service_not_enabled` | 404 | This service is not enabled for the project. | The Data API is not switched on for this project, or the project has no database yet. |
| `cors_origin_not_allowed` | 403 | This origin is not allowed. | The browser's origin is not on the project's list of allowed origins. Add it in the Data API settings. |
| `request_body_too_large` | 413 | The request body is too large. | The request body is larger than the project's request size limit. Send fewer rows per request. |
| `filter_required` | 400 | Updates and deletes need a filter. | An update or a delete on a table named no filter. Add a filter such as `id=eq.1`; `limit`, `order` and `select` do not count. |
| `data_request_invalid` | 400 | The request is not a valid query. | The query could not be read: a filter, `select`, body or preference is not valid, or names a column that does not exist. |
| `data_method_not_allowed` | 405 | This method is not allowed on this resource. | The table, view or function does not take this method, such as a write to a read-only view or a `GET` on a function that changes data. |
| `data_not_acceptable` | 406 | The requested representation is not available. | The `Accept` media type cannot be produced, or a single object was asked for and the result is not exactly one row. |
| `data_range_not_satisfiable` | 416 | The requested range is outside the result. | The requested range starts past the end of the result. |
| `data_reference_ambiguous` | 300 | More than one relationship or function matches. Name one. | More than one relationship or function overload matches. Name the one you mean. |
| `data_resource_not_found` | 404 | The table, view or function was not found, or is not exposed. | The table, view or function does not exist, or is in a schema the project does not expose. |
| `data_access_denied` | 403 | This key's role may not do this. | The key's role may not do this: the table is not exposed to the Data API for this operation, or a row-level security policy refused the row. |
| `row_conflict` | 409 | A row with the same unique value already exists, or a referenced row is missing. | A row with the same unique value already exists, or a referenced row is missing. |
| `row_invalid` | 400 | The row breaks a rule of the table. | A value breaks a rule of the table: a missing required value, a failed check, or a value of the wrong type. |
| `max_affected_exceeded` | 400 | The change would affect more rows than allowed. | The update or delete would change more rows than allowed, so nothing changed. Narrow the filter, or raise your own `max-affected` up to the project's limit. |
| `data_function_raised` | 400 | The function raised an error. | Your function raised an error. The detail is your function's own message. |
| `data_function_response_invalid` | 500 | The function set an invalid response status or headers. | Your function set an invalid response status or headers. Fix the function. |
| `idempotency_key_invalid` | 400 | The Idempotency-Key header is not in the accepted format. | The `Idempotency-Key` header is not 1 to 255 letters, digits, `_` or `-`, or was sent twice. |
| `idempotency_key_reused` | 422 | This Idempotency-Key was already used with a different request. Send a new key for a new request. | This `Idempotency-Key` was used for a different request. Send a new key for a new request. |
| `idempotency_request_in_progress` | 409 | A request with this Idempotency-Key is still in progress. Retry once it has finished. | A request with this `Idempotency-Key` is still running. Retry after it finishes. |
| `database_resuming` | 503 | The database is starting. Retry after the time in Retry-After. | The database was paused for being idle and is starting now. Retry after the seconds in `Retry-After`. |
| `database_paused` | 409 | The database is paused. Resume it first. | The database is paused by its owner, the operator, or for its storage limit. A retry does not resume it: resume it in the console. |
| `database_read_only` | 409 | The database is read-only: it is over its storage limit. Delete data or move to a plan with more storage; writes work again once it is under the limit. | The database is over its storage limit, so the Data API takes `GET` and `HEAD` only (call functions with `GET`). Delete data in the console's table editor or over a direct connection, or move to a plan with more storage; writes work again once it is under the limit. |
| `database_not_ready` | 409 | The database is not ready for this. Check its state and try again. | The database is being created, restored or deleted, or cannot serve in its current state. Retry once it is ready. |
| `rate_limited` | 429 | Too many requests. | The key sent more requests than its rate allows. Retry after `Retry-After`. |
| `quota_exceeded` | 429 | The project has used its quota for this period. It resets at the time in Retry-After. | The project used its request quota for this period. It resets at the time in `Retry-After`. |
| `query_timeout` | 504 | The query took too long and was cancelled. | The query ran longer than its statement timeout and was cancelled. Narrow it, or add an index. |
| `service_timeout` | 504 | The service did not answer in time. | The request did not finish within the time allowed, or waited too long for a database connection. Retry, or make the query cheaper. |
| `not_implemented` | 501 | This feature is not implemented yet. | This operation is not available on this project's Data API. |
| `unavailable` | 503 | The service is temporarily unavailable. | The Data API cannot serve the project right now, for example while it starts or reconnects. Retry after `Retry-After`. |
| `internal` | 500 | Something went wrong. | Something failed on our side. Retry; if it persists, contact support with the `x-request-id` of the answer. |

### A status your function sets

A function that sets its own response status gets that status and the code
below, with your function's message as `detail`. Any other 4xx status takes
`bad_request`; any other status takes `internal`.

| Status your function sets | Code |
| - | - |
| 400 | `bad_request` |
| 401 | `unauthenticated` |
| 403 | `forbidden` |
| 404 | `not_found` |
| 409 | `conflict` |
| 422 | `unprocessable` |
| 429 | `rate_limited` |
| 500 | `internal` |
| 501 | `not_implemented` |
| 502 | `provider_failure` |
| 503 | `unavailable` |

## Client example

The Data API speaks the grammar `@supabase/postgrest-js` writes; version
2.118.0 is the one our compatibility test runs. This snippet is run by that
test as written here:

```ts theme={null}
import { PostgrestClient } from "@supabase/postgrest-js";

const db = new PostgrestClient("https://<slug>.<project-domain>/rest/v1", {
  headers: { apikey: "dtn_pk_..." },
});

const { data, error } = await db
  .from("todos")
  .select("id,title")
  .eq("done", false)
  .limit(20);
```

Pass only the `apikey` header: the client then sends no `Authorization`
header of its own.

## Not supported

| Feature | Answer |
| - | - |
| Aggregates (`count()`, `sum()` in `select`) | refused |
| Query plans (`Accept: application/vnd.pgrst.plan`) | refused |
| `Prefer: tx=` | dropped |
| Full-text and JSON path filters, other range and array operators | not part of this API yet |
| CSV and GeoJSON answers | not part of this API yet |
| The key in the URL | refused with `api_key_in_url` |
| User sign-in tokens | refused with `user_token_invalid` |

## Related

* [Docs index](../README.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.