API documentation

Start with REST

Every project has a standard HTTP API. Query rows, change your schema, add relationships, or search your content by meaning. Start with a working example and adapt it to your app.

Quickstart

Copy the project URL and an API key from the dashboard. Send the key in the apikey header. Use a publishable key in apps where row-level security applies; keep secret keys on your server.

export PROJECT_URL="https://www.starterpg.com/api/v1/your-project"
export API_KEY="your-api-key"

curl "$PROJECT_URL/rest/todos?done=eq.false&order=id.desc" \
  -H "apikey: $API_KEY"

Data REST API

Replace todos with any table in your public schema. Filters use column=operator.value.

GET/rest/{table}

Read and filter rows from a table.

Access
Any valid API key
Returns
JSON array of matching rows
Parameters
tableRequired
pathstring

Public table name.

select
querystring

Comma-separated columns to return.

order
querystring

Column and direction, such as id.desc.

limit
queryinteger

Maximum rows to return.

{column}
queryfilter

Filter using operator.value, such as done=eq.false.

curl "$PROJECT_URL/rest/todos?select=id,task&done=eq.false&limit=20" \
  -H "apikey: $API_KEY"
POST/rest/{table}

Insert one row or several rows.

Access
Any valid API key
Returns
Inserted row data
Parameters
tableRequired
pathstring

Public table name.

bodyRequired
bodyobject | object[]

One row object or an array of row objects.

curl -X POST "$PROJECT_URL/rest/todos" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{"task":"ship the release","done":false}'
PATCH/rest/{table}?{filter}

Update every row matching the required filter.

Access
Any valid API key
Returns
Updated row data
Parameters
tableRequired
pathstring

Public table name.

{column}Required
queryfilter

Rows to update, such as id=eq.42.

bodyRequired
bodyobject

Column values to change.

DELETE/rest/{table}?{filter}

Delete every row matching the required filter.

Access
Any valid API key
Returns
Deleted row data
Parameters
tableRequired
pathstring

Public table name.

{column}Required
queryfilter

Rows to delete, such as id=eq.42.

Schema

Inspecting schema needs schema read access. Changing it needs a secret key with schema write access.

GET/schema

List public tables, columns, keys, and relationships.

Access
Any valid API key
Returns
{ tables: Table[] }
POST/schema

Validate and apply one schema change.

Access
Secret key with schema write access
Returns
{ sql: string, executed: boolean }
Parameters
preview
queryboolean

Set to true to validate and return SQL without executing it.

kindRequired
bodystring

Schema operation to perform.

schema
bodystring

Schema name. Defaults to public.

curl -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "kind": "create_table",
    "name": "todos",
    "columns": [
      { "name": "id", "type": "serial", "primaryKey": true },
      { "name": "task", "type": "text", "nullable": false },
      { "name": "done", "type": "boolean", "default": "false" }
    ]
  }'
OperationRequired fields
create_tablename, columns
Optionalschema
drop_tablename
Optionalschema, cascade
rename_tablefrom, to
Optionalschema
add_columntable, column
Optionalschema
drop_columntable, column
Optionalschema
rename_columntable, from, to
Optionalschema
create_indextable, columns
Optionalschema, unique, method, opclass, lists
drop_indexname
Optionalschema

Relationships with foreign keys

Yes — create a relationship by setting references on a column in create_table or add_column. PostgreSQL enforces it on inserts, updates, and deletes. The REST API discovers the foreign key and can return related rows in one request. These examples use the PROJECT_URL from the quickstart and a server-side secret API_KEY with schema and REST read/write/delete access. Run them in a disposable project with no existing authors or posts tables.

1. Create the parent, then the child

The referenced column must be a primary key or unique, with a compatible type. Here, many posts belong to one author. Append ?preview=true to a schema request to see the SQL without executing it. For atomic creation, put both operations in a migration in this same order.

curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{
    "kind": "create_table", "name": "authors",
    "columns": [
      { "name": "id", "type": "integer", "primaryKey": true },
      { "name": "name", "type": "text", "nullable": false }
    ]
  }'

curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{
    "kind": "create_table", "name": "posts",
    "columns": [
      { "name": "id", "type": "integer", "primaryKey": true },
      { "name": "title", "type": "text" },
      {
        "name": "author_id", "type": "integer", "nullable": false,
        "references": { "table": "authors", "column": "id", "onDelete": "CASCADE" }
      }
    ]
  }'

2. Insert related rows

Schema changes return HTTP 200 with { sql, executed }. REST inserts return HTTP 201 and an array of inserted rows, even for a single row. Updates and deletes return HTTP 200 with row arrays, or an empty 204 with Prefer: return=minimal.

curl --fail-with-body -X POST "$PROJECT_URL/rest/authors" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '[{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]'

curl --fail-with-body -X POST "$PROJECT_URL/rest/posts" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{"id":10,"author_id":1,"title":"First post"}'

Inserting or updating a post with a nonexistent author fails with HTTP 400 and PostgreSQL code: "23503". Foreign keys do not create missing parent rows automatically.

3. Query in either direction

# Many-to-one: each post includes its author object.
curl --fail-with-body --get "$PROJECT_URL/rest/posts" \
  -H "apikey: $API_KEY" \
  --data-urlencode 'select=id,title,author:authors(id,name)' \
  --data-urlencode 'order=id.asc'

# One-to-many: each author includes an array of posts.
curl --fail-with-body --get "$PROJECT_URL/rest/authors" \
  -H "apikey: $API_KEY" \
  --data-urlencode 'select=id,name,posts(id,title)' \
  --data-urlencode 'order=id.asc'

The second request returns:

[
  { "id": 1, "name": "Ada", "posts": [{ "id": 10, "title": "First post" }] },
  { "id": 2, "name": "Grace", "posts": [] }
]

Missing or RLS-hidden parents become null; no visible children produces []. Publishable keys need access rules on both tables. An FK guarantees that a row exists, not that it belongs to the current user. Use ownership policies or an appropriate database constraint for that separate requirement.

4. Add a foreign-key column to an existing table

curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{
    "kind": "add_column", "table": "posts",
    "column": {
      "name": "reviewer_id", "type": "integer",
      "references": { "table": "authors", "column": "id", "onDelete": "SET NULL" }
    }
  }'

curl --fail-with-body -X PATCH "$PROJECT_URL/rest/posts?id=eq.10" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{"reviewer_id":2}'

# Two foreign keys now point to authors: choose each relationship explicitly.
curl --fail-with-body --get "$PROJECT_URL/rest/posts" \
  -H "apikey: $API_KEY" \
  --data-urlencode 'select=id,author:authors!author_id(name),reviewer:authors!reviewer_id(name)'

Existing rows get null for the new nullable column. Adding a NOT NULL column requires a valid default. With multiple foreign keys to the same table, an unqualified embed is ambiguous and returns HTTP 400; use !column_name or !constraint_name to select the relationship. Inspect GET /schema for each table's foreignKeys, including the constraint name.

5. Choose what deletion means

  • CASCADE: deleting the author also deletes their posts.
  • SET NULL: keeps the post and clears its reviewer; the column must allow null.
  • RESTRICT or NO ACTION: blocks deletion while a child references the parent. Omission defaults to PostgreSQL's NO ACTION.
# Clears reviewer_id on post 10, leaving the post in place.
curl --fail-with-body -X DELETE "$PROJECT_URL/rest/authors?id=eq.2" \
  -H "apikey: $API_KEY"

# Deletes author 1 AND post 10 through ON DELETE CASCADE.
curl --fail-with-body -X DELETE "$PROJECT_URL/rest/authors?id=eq.1" \
  -H "apikey: $API_KEY"

The structured schema API supports single-column references on new tables or new columns. To attach a constraint to a column that already exists, or create composite/cross-schema keys, use the dashboard SQL editor or a direct PostgreSQL connection. Composite and cross-schema relationships are not covered by these REST embedding examples.

Auto-embedding: copy, paste, search

Send plain text, not vectors. starterpg automatically generates embeddings when you insert or update text with ?embed=true, and embeds your search question too. Choose your language for a complete working example: create a table, import help articles, search, filter, update, and search again. No embedding-provider key, SDK, or custom SQL function is needed.

  1. In your project's Extensions, enable Vector, then Embed.
  2. Copy the project API URL and a secret key from the API page. Set these environment variables in your terminal, replacing the placeholders.
  3. Choose one language below, copy the entire script, save it, and run it as indicated. Each language performs the same operations; you do not need to run all four.
Environment (macOS / Linux shell)
export PROJECT_URL="https://www.starterpg.com/api/v1/your-project"
export API_KEY="your-server-side-secret-key"

Run server-side only: never expose this secret key in browser or mobile code. Use a project without a help_articles table, or skip the first schema request if you already created the same table in the walkthrough. The examples use vector(768) and upsert sample IDs 1–3, so use a demo table, not production data. Writes and searches consume your embedding allowance. Search output contains id, title, content, and score; scores depend on your content.

Save as auto-embed.sh and run: bash auto-embed.sh. Requires Bash and cURL 7.76+.

#!/usr/bin/env bash
set -euo pipefail
: "${PROJECT_URL:?Set PROJECT_URL to your project API URL}"
: "${API_KEY:?Set API_KEY to your server-side secret key}"
export PROJECT_URL="${PROJECT_URL%/}"

# Create the table once (skip this step if it already exists)
curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "kind": "create_table",
  "name": "help_articles",
  "columns": [
    {
      "name": "id",
      "type": "integer",
      "primaryKey": true
    },
    {
      "name": "title",
      "type": "text",
      "nullable": false
    },
    {
      "name": "category",
      "type": "text",
      "nullable": false
    },
    {
      "name": "published",
      "type": "boolean",
      "default": "false"
    },
    {
      "name": "content",
      "type": "text",
      "nullable": false
    },
    {
      "name": "embedding",
      "type": "vector(768)",
      "nullable": false
    }
  ]
}'
printf '\n'

# Send text; starterpg automatically generates and stores embeddings
curl --fail-with-body -X POST "$PROJECT_URL/rest/help_articles?embed=true&on_conflict=id" \
  -H "apikey: $API_KEY" \
  -H "Prefer: resolution=merge-duplicates,return=minimal" \
  -H "content-type: application/json" \
  -d '[
  {
    "id": 1,
    "title": "Reset your password",
    "category": "account",
    "published": true,
    "content": "Use Forgot password on the login page to receive a reset link by email."
  },
  {
    "id": 2,
    "title": "Change your plan",
    "category": "billing",
    "published": true,
    "content": "Open Billing in your dashboard to upgrade your subscription or change your plan."
  },
  {
    "id": 3,
    "title": "Invite a teammate",
    "category": "account",
    "published": false,
    "content": "Open your team settings and send an invitation to a colleague by email."
  }
]'
printf '\n'

# Search with a sentence; starterpg also embeds the query
curl --fail-with-body -X POST "$PROJECT_URL/vector/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "text": "I cannot sign in because I forgot my password",
  "column": "embedding",
  "metric": "cosine",
  "select": "id,title,content",
  "limit": 3
}'
printf '\n'

# Search only published articles in the account category
curl --fail-with-body -X POST "$PROJECT_URL/vector/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "text": "I cannot sign in because I forgot my password",
  "column": "embedding",
  "metric": "cosine",
  "select": "id,title,content",
  "limit": 3,
  "filters": {
    "category": "eq.account",
    "published": "eq.true"
  }
}'
printf '\n'

# Update text and automatically regenerate its embedding
curl --fail-with-body -X PATCH "$PROJECT_URL/rest/help_articles?id=eq.1&embed=true" \
  -H "apikey: $API_KEY" \
  -H "Prefer: return=minimal" \
  -H "content-type: application/json" \
  -d '{
  "content": "Choose Forgot password on the login page. Open the emailed reset link and choose a new password."
}'
printf '\n'

# Search again using the updated content
curl --fail-with-body -X POST "$PROJECT_URL/vector/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "text": "I cannot sign in because I forgot my password",
  "column": "embedding",
  "metric": "cosine",
  "select": "id,title,content",
  "limit": 3
}'
printf '\n'

Use the same pattern for product discovery, a searchable FAQ, or internal knowledge search: replace the article text with your content and search in natural language. Include ?embed=true when searchable text changes; without it, a normal text update leaves the old embedding unchanged. See the step-by-step guide for indexing, relevance tuning, RLS, and troubleshooting.

Text search in three steps

Turn on Vector and Embed, store your text, then search using a sentence. starterpg generates the document and query embeddings for you. You do not need a custom SQL matching function, a separate vector database, or your own embedding-provider integration to use hosted Embed.

Prefer a complete script? Copy the cURL, JavaScript, Python, or Go example.

1. Enable it in your project
  1. Open your project in the dashboard and select Extensions.
  2. Turn Vector on, then turn Embed on.
  3. Copy the project URL and a secret API key from the project's API page.

Vector stores/searches numeric vectors. Embed converts text to vector(768). Both toggles are project settings; ?embed=true also opts each request into text conversion. Keep the secret key on your server, never in a browser or mobile bundle.

export PROJECT_URL="https://www.starterpg.com/api/v1/your-project"
export API_KEY="your-server-side-secret-key"

Run this example in a project without a help_articles table. Your key needs schema write, REST write/read and vector read access. Hosted embedding requests use your plan's monthly embedding allowance.

2. Create a table and send text

curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "kind": "create_table",
  "name": "help_articles",
  "columns": [
    {
      "name": "id",
      "type": "integer",
      "primaryKey": true
    },
    {
      "name": "title",
      "type": "text",
      "nullable": false
    },
    {
      "name": "category",
      "type": "text",
      "nullable": false
    },
    {
      "name": "published",
      "type": "boolean",
      "default": "false"
    },
    {
      "name": "content",
      "type": "text",
      "nullable": false
    },
    {
      "name": "embedding",
      "type": "vector(768)",
      "nullable": false
    }
  ]
}'

Insert one object or a batch. With one vector column and no explicit embedding value, starterpg embeds content automatically. The original text remains available for display. This creates three rows with 768-dimensional embeddings; you never have to send 768 numbers.

curl --fail-with-body -X POST "$PROJECT_URL/rest/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '[
  {
    "id": 1,
    "title": "Reset your password",
    "category": "account",
    "published": true,
    "content": "Use Forgot password on the login page to receive a reset link by email."
  },
  {
    "id": 2,
    "title": "Change your plan",
    "category": "billing",
    "published": true,
    "content": "Open Billing in your dashboard to upgrade your subscription or change your plan."
  },
  {
    "id": 3,
    "title": "Invite a teammate",
    "category": "account",
    "published": false,
    "content": "Open your team settings and send an invitation to a colleague by email."
  }
]'

Inserts return HTTP 201 and row data. To avoid returning large embeddings, add -H "Prefer: return=minimal"; a successful write then returns an empty 204 response.

3. Search with a sentence

curl --fail-with-body -X POST "$PROJECT_URL/vector/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "text": "I cannot sign in because I forgot my password",
  "column": "embedding",
  "metric": "cosine",
  "select": "id,title,content",
  "limit": 3
}'

The query text is embedded automatically, then compared with stored vectors. The response is a JSON array of selected columns plus score, ordered nearest first. The password article is a relevant candidate even though the question does not repeat its title exactly. Scores and exact ordering depend on the model and your content.

// Illustrative response shape, not a guaranteed score or ranking:
[
  {
    "id": 1,
    "title": "Reset your password",
    "content": "Use Forgot password on the login page to receive a reset link by email.",
    "score": 0.82
  }
]
Starting with existing vectors? Only turn on Vector; leave Embed off and use the bring-your-own-vectors example. Use the same model and dimensionality for your stored vectors and queries. The Vector search starter currently uses vector(1536), not the hosted Embed dimension. For hosted text search, use the 768-dimensional table above instead of mixing the two examples.

Search only published articles in a category

curl --fail-with-body -X POST "$PROJECT_URL/vector/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "text": "I cannot sign in because I forgot my password",
  "column": "embedding",
  "metric": "cosine",
  "select": "id,title,content",
  "limit": 3,
  "filters": {
    "category": "eq.account",
    "published": "eq.true"
  }
}'

Filters narrow the candidate rows before nearest-neighbor ranking. Vector filters support eq, neq, gt, gte, lt, lte, like and ilike, using actual column names. Unlike REST reads, they do not support in, logical groups, or embedded joins. A user-supplied filter is not an authorization rule: use RLS with a publishable key and the user's access token for per-user visibility.

Require a minimum cosine score

curl --fail-with-body -X POST "$PROJECT_URL/vector/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "text": "I cannot sign in because I forgot my password",
  "column": "embedding",
  "metric": "cosine",
  "select": "id,title,content",
  "limit": 3,
  "filters": {
    "category": "eq.account",
    "published": "eq.true"
  },
  "threshold": 0.6
}'

Start without a threshold, inspect the results, then tune it for your dataset. 0.6 is an example, not a universal relevance cutoff or a confidence percentage. The threshold is applied to the top limit candidates, so fewer rows (or []) may be returned. The default limit is 10 and the maximum is 200.

Choose exactly which text gets embedded

curl --fail-with-body -X POST "$PROJECT_URL/rest/help_articles?embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "id": 4,
  "title": "Update your email",
  "category": "account",
  "published": true,
  "content": "Open account settings and verify your new email address.",
  "embedding": "Change email address. Update account contact details. Verify a new email."
}'

A string in the vector column overrides automatic text selection. Use this for a title plus body, a product description plus category, or a searchable summary. If you omit it and the table has exactly one vector column, the first nonempty field from content, body, text, title, task, nameis used; those fields are not concatenated. With multiple vector columns, explicitly supply text for the columns you want embedded.

Update content and regenerate its embedding

curl --fail-with-body -X PATCH "$PROJECT_URL/rest/help_articles?id=eq.1&embed=true" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "content": "Choose Forgot password on the login page. Open the emailed reset link and choose a new password."
}'

The filter limits the update to article 1. Re-embed whenever its searchable text changes; updating text without the flag leaves the previous embedding unchanged. A metadata-only update needs no new vector and should omit the flag:

curl --fail-with-body -X PATCH "$PROJECT_URL/rest/help_articles?id=eq.3" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "published": true
}'

Repeat an import safely with upsert

curl --fail-with-body -X POST "$PROJECT_URL/rest/help_articles?embed=true&on_conflict=id" \
  -H "apikey: $API_KEY" \
  -H "Prefer: resolution=merge-duplicates,return=minimal" \
  -H "content-type: application/json" \
  -d '[
  {
    "id": 1,
    "title": "Reset your password",
    "category": "account",
    "published": true,
    "content": "Use Forgot password on the login page to receive a reset link by email."
  },
  {
    "id": 2,
    "title": "Change your plan",
    "category": "billing",
    "published": true,
    "content": "Open Billing in your dashboard to upgrade your subscription or change your plan."
  },
  {
    "id": 3,
    "title": "Invite a teammate",
    "category": "account",
    "published": false,
    "content": "Open your team settings and send an invitation to a colleague by email."
  }
]'

Existing IDs are updated rather than duplicated; missing IDs are inserted. This request re-embeds the supplied text, including existing rows, and uses embedding allowance again. Send only changed documents when possible.

Add an index as the collection grows

curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "kind": "create_index",
  "table": "help_articles",
  "columns": [
    "embedding"
  ],
  "method": "hnsw",
  "opclass": "vector_cosine_ops"
}'

Search works without an index. HNSW speeds up larger collections using approximate nearest-neighbor search, which can trade recall for speed. The operator class must match the metric you query. Filtered approximate searches may return fewer than the requested number of neighbors.

Use JavaScript fetch from your server

const projectUrl = process.env.PROJECT_URL.replace(/[/]$/, "");
const apiKey = process.env.API_KEY; // server only

async function postJson(resource, body, extraHeaders = {}) {
  const response = await fetch(projectUrl + resource, {
    method: "POST",
    headers: { apikey: apiKey, "content-type": "application/json", ...extraHeaders },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(await response.text());
  return response.status === 204 ? null : response.json();
}

// The table above must exist and both Vector and Embed must be enabled.
await postJson("/rest/help_articles?embed=true&on_conflict=id", {
  id: 5, title: "Download invoices", category: "billing", published: true,
  content: "Open Billing, then Invoices, to download a PDF receipt.",
}, { Prefer: "resolution=merge-duplicates,return=minimal" });

const results = await postJson("/vector/help_articles?embed=true", {
  text: "Where can I get a receipt for my payment?",
  column: "embedding", metric: "cosine", limit: 5,
  select: "id,title,content", filters: { published: "eq.true" },
});
console.log(results);

Use the results as context for an answer

For retrieval-augmented generation (RAG), run the search above, then pass only the returned content to your chosen chat model. Keep article IDs for citations. starterpg handles retrieval, not answer generation. Treat retrieved text as untrusted source material rather than instructions.

const sources = results.map(({ id, title, content }) => ({ id, title, content }));
// Send these sources and the user's question to your own answer-generation step.
// If sources is empty, say that no matching material was found.
console.log(JSON.stringify(sources, null, 2));

Everyday data recipes

These examples use the quickstart's project URL and server-side secret key. Create this separate demo table first, then run any of the queries.

curl --fail-with-body -X POST "$PROJECT_URL/schema" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "kind": "create_table",
  "name": "demo_tasks",
  "columns": [
    {
      "name": "id",
      "type": "integer",
      "primaryKey": true
    },
    {
      "name": "task",
      "type": "text",
      "nullable": false
    },
    {
      "name": "done",
      "type": "boolean",
      "default": "false"
    },
    {
      "name": "priority",
      "type": "integer",
      "default": "1"
    },
    {
      "name": "due_on",
      "type": "date"
    }
  ]
}'
curl --fail-with-body -X POST "$PROJECT_URL/rest/demo_tasks" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '[
  {
    "id": 1,
    "task": "Write the API guide",
    "done": false,
    "priority": 3,
    "due_on": "2026-10-01"
  },
  {
    "id": 2,
    "task": "Ship search",
    "done": false,
    "priority": 2,
    "due_on": null
  },
  {
    "id": 3,
    "task": "Review the guide",
    "done": true,
    "priority": 1,
    "due_on": null
  }
]'

Filter, sort and paginate

# First page of unfinished tasks; show the Content-Range response header.
curl --fail-with-body -i --get "$PROJECT_URL/rest/demo_tasks" \
  -H "apikey: $API_KEY" -H "Prefer: count=exact" \
  --data-urlencode 'select=id,task,priority' \
  --data-urlencode 'done=eq.false' \
  --data-urlencode 'order=priority.desc,id.asc' \
  --data-urlencode 'limit=1' --data-urlencode 'offset=0'

# Change offset to 1 for the next page. Initial count is Content-Range: 0-0/2.
# Include a unique tie-breaker (id) for stable pagination.

Find text, match a list, or combine conditions

# Case-insensitive substring search.
curl --fail-with-body --get "$PROJECT_URL/rest/demo_tasks" \
  -H "apikey: $API_KEY" --data-urlencode 'task=ilike.%guide%'

# Select several IDs.
curl --fail-with-body --get "$PROJECT_URL/rest/demo_tasks" \
  -H "apikey: $API_KEY" --data-urlencode 'id=in.(1,3)'

# Tasks without a due date.
curl --fail-with-body --get "$PROJECT_URL/rest/demo_tasks" \
  -H "apikey: $API_KEY" --data-urlencode 'due_on=is.null'

# High-priority OR unfinished; separate query parameters are combined with AND.
curl --fail-with-body --get "$PROJECT_URL/rest/demo_tasks" \
  -H "apikey: $API_KEY" --data-urlencode 'or=(priority.gte.3,done.eq.false)'

Update, upsert and delete

curl --fail-with-body -X PATCH "$PROJECT_URL/rest/demo_tasks?id=eq.1" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "done": true
}'
curl --fail-with-body -X POST "$PROJECT_URL/rest/demo_tasks?on_conflict=id" \
  -H "apikey: $API_KEY" \
  -H "Prefer: resolution=merge-duplicates" \
  -H "content-type: application/json" \
  -d '{
  "id": 2,
  "task": "Ship semantic search",
  "priority": 3
}'
# Destructive: delete only task 3 and suppress the response body (204).
curl --fail-with-body -X DELETE "$PROJECT_URL/rest/demo_tasks?id=eq.3" \
  -H "apikey: $API_KEY" -H "Prefer: return=minimal"

PATCH changes only supplied columns. Upsert inserts or merges on a primary-key/unique conflict; supply the complete intended row where defaults matter. Always filter updates and deletes. Bulk POST sends an array of row objects; rows should use a consistent set of fields.

App users and authenticated requests

These are users of your app, not dashboard accounts. Signup and login require a secret key and belong in your server backend. After login, clients can use a publishable key plus the user's access token. Enable the intended access rules on your tables first; signing in does not grant unrestricted database access.

# Server-side signup. Use your own test email and a strong password.
export END_USER_EMAIL="reader@example.com"
export END_USER_PASSWORD="replace-with-a-strong-test-password"

# These shell recipes require jq to safely build/parse JSON.
LOGIN_BODY=$(jq -n --arg email "$END_USER_EMAIL" --arg password "$END_USER_PASSWORD" \
  '{email: $email, password: $password}')

curl --fail-with-body -X POST "$PROJECT_URL/auth/signup" \
  -H "apikey: $API_KEY" -H "content-type: application/json" -d "$LOGIN_BODY"

# Skip signup if the user already exists; duplicate email returns 409.
SESSION=$(curl --fail-with-body -X POST "$PROJECT_URL/auth/login" \
  -H "apikey: $API_KEY" -H "content-type: application/json" -d "$LOGIN_BODY")
ACCESS_TOKEN=$(printf '%s' "$SESSION" | jq -er '.access_token')
REFRESH_TOKEN=$(printf '%s' "$SESSION" | jq -er '.refresh_token')
export PUBLISHABLE_KEY="your-publishable-key"

curl --fail-with-body "$PROJECT_URL/auth/me" \
  -H "apikey: $PUBLISHABLE_KEY" -H "Authorization: Bearer $ACCESS_TOKEN"

To read a table as that user, send these same two headers to /rest/your_table. RLS determines which rows are visible. Keep refresh tokens private and use secure session storage appropriate to your app; do not log tokens or publish the shell output.

Refresh once, then sign out

REFRESH_BODY=$(jq -n --arg token "$REFRESH_TOKEN" '{refresh_token: $token}')
SESSION=$(curl --fail-with-body -X POST "$PROJECT_URL/auth/refresh" \
  -H "apikey: $PUBLISHABLE_KEY" -H "content-type: application/json" -d "$REFRESH_BODY")
ACCESS_TOKEN=$(printf '%s' "$SESSION" | jq -er '.access_token')
REFRESH_TOKEN=$(printf '%s' "$SESSION" | jq -er '.refresh_token')

# Always replace the previous refresh token; rotation is single-use.
LOGOUT_BODY=$(jq -n --arg token "$REFRESH_TOKEN" '{refresh_token: $token}')
curl --fail-with-body -X POST "$PROJECT_URL/auth/logout" \
  -H "apikey: $PUBLISHABLE_KEY" -H "content-type: application/json" -d "$LOGOUT_BODY"
unset SESSION ACCESS_TOKEN REFRESH_TOKEN END_USER_PASSWORD LOGIN_BODY REFRESH_BODY LOGOUT_BODY

Logout returns an empty 204 and revokes the refresh token. An existing stateless access token remains valid until it expires (up to one hour). Reusing an already-rotated refresh token revokes the user's sessions; serialize refresh requests instead of refreshing concurrently.

Upload and download files

These examples run on your server using a secret key with storage access. Do not assume table RLS governs object access; authorize app users in your backend before handing out signed URLs. URLs are temporary access credentials, so share them only with the intended recipient.

curl --fail-with-body -X POST "$PROJECT_URL/storage/buckets" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "name": "demo-files",
  "public": false
}'
# Small upload through the API: raw bytes, not a JSON object.
curl --fail-with-body -X PUT "$PROJECT_URL/storage/objects?bucket=demo-files&path=hello.txt" \
  -H "apikey: $API_KEY" -H "content-type: text/plain" \
  --data-binary 'Hello from starterpg storage!'

curl --fail-with-body "$PROJECT_URL/storage/objects?bucket=demo-files" \
  -H "apikey: $API_KEY"

# Get a download URL (requires jq); fetch it WITHOUT forwarding your API key.
DOWNLOAD_URL=$(curl --fail-with-body \
  "$PROJECT_URL/storage/objects?bucket=demo-files&path=hello.txt" \
  -H "apikey: $API_KEY" | jq -er '.url')
curl --fail-with-body "$DOWNLOAD_URL"

Upload directly using a signed URL

Signed uploads bypass the app's request-body limit (about 4.5 MB on Vercel). Use the same content type when signing and uploading. Confirm the upload afterward so it appears in object listings.

UPLOAD_URL=$(curl --fail-with-body -X POST "$PROJECT_URL/storage/sign" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{"action":"upload","bucket":"demo-files","path":"direct.txt","contentType":"text/plain"}' \
  | jq -er '.url')

# No apikey header: this URL already authorizes the GCS upload.
curl --fail-with-body -X PUT "$UPLOAD_URL" \
  -H "content-type: text/plain" --data-binary 'Uploaded directly to storage'

curl --fail-with-body -X POST "$PROJECT_URL/storage/objects" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{"bucket":"demo-files","path":"direct.txt"}'

# Delete both example objects, then the now-empty logical bucket.
curl --fail-with-body -X DELETE "$PROJECT_URL/storage/objects?bucket=demo-files&path=hello.txt" -H "apikey: $API_KEY"
curl --fail-with-body -X DELETE "$PROJECT_URL/storage/objects?bucket=demo-files&path=direct.txt" -H "apikey: $API_KEY"
curl --fail-with-body -X DELETE "$PROJECT_URL/storage/buckets?name=demo-files" -H "apikey: $API_KEY"

Call a database function

Create this function in your project's SQL editor, then call it over HTTP. Arguments are named JSON properties, not a positional array. No custom function is needed for the built-in vector search examples above.

CREATE OR REPLACE FUNCTION public.add_numbers(a integer, b integer)
RETURNS integer
LANGUAGE sql IMMUTABLE
AS $$ SELECT a + b $$;
curl --fail-with-body -X POST "$PROJECT_URL/rpc/add_numbers" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "a": 7,
  "b": 5
}'

The scalar response is 12. Set-returning functions return an array of rows. The function must be in the exposed public schema and the key must have RPC access. Publishable-key calls also obey database privileges and row-level policies.

Migrations

Use migrations during deployment when several schema changes belong together. Repeating the same ID and operations is safe: it returns applied: false. Reusing an ID for different operations returns 409.

POST/schema/migrations

Apply an ordered group of changes. Either every operation succeeds or none are kept.

Access
Secret key with schema write access
Returns
{ migration, applied }
Parameters
idRequired
bodystring

Stable unique migration ID, up to 100 characters.

name
bodystring

Human-readable label, up to 120 characters.

operationsRequired
bodyoperation[]

Ordered schema operations. At least one is required.

curl -X POST "$PROJECT_URL/schema/migrations" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "id": "2026-09-17-add-todos",
    "name": "Add todos",
    "operations": [
      {
        "kind": "create_table",
        "name": "todos",
        "columns": [
          { "name": "id", "type": "serial", "primaryKey": true },
          { "name": "task", "type": "text", "nullable": false }
        ]
      },
      {
        "kind": "create_index",
        "table": "todos",
        "columns": ["task"]
      }
    ]
  }'
GET/schema/migrations

List the latest applied migrations.

Access
Any valid API key
Returns
{ migrations: { id, name, appliedAt }[] }

Starters

GET/schema/templates

List available starters and how many tables each adds.

Access
Any valid API key
Returns
{ templates: Template[] }
POST/schema/templates

Apply a starter to an empty project.

Access
Secret key with schema write access
Returns
{ template, tables, applied }
Parameters
templateRequired
bodystring

One of: saas, blog, ecommerce, or vector-search.

curl -X POST "$PROJECT_URL/schema/templates" \
  -H "apikey: $API_KEY" \
  -H "content-type: application/json" \
  -d '{"template":"saas"}'

Usage and activity

GET/schema/usage

Read estimated rows and total size for each public table.

Access
Any valid API key
Returns
{ tables: TableUsage[], updatedAt }
GET/schema/changes

Read recent schema activity with actor or source key, generated SQL, timestamp, and outcome. Row data and credentials are never included.

Access
Any valid API key
Returns
{ changes: { source, actorEmail, sourceKeyName, action, objectName, generatedSql, outcome, errorMessage, createdAt }[] }

Errors and access

400

The request is invalid.

401

The API key is missing or invalid.

403

The key does not have access to this action.

409

The request conflicts with current project state.

429

The request limit was reached. Retry after the indicated delay.

{
  "error": "A migration needs at least one operation"
}

SDKs are optional

JavaScript, Python, and Go clients provide thin conveniences over these same REST resources. REST remains the complete and primary API.