# Frontend data API

> Call your project's tables straight from a browser with a publishable key, and choose per table what that key can read, create, update and delete.

Source: https://docs.fortly.com/data/frontend-api

The frontend data API lets a page in someone's browser read and write your project's tables without a server in between. It is served from your project's own address under `/_data`, and it takes a **publishable key** (`cvp_…`) that is safe to ship in your page. The key can do only what you switch on for each table, and every table starts with everything switched off.

## How it stays safe

Three rules make a key you publish safe to publish:

- **Every table starts closed.** Read, create, update and delete are four separate switches per table. A table nobody has decided about can't be reached, so if you forget one, it stays closed.
- **A key belongs to one project.** A key for one project sent to another project's address is refused. It is not quietly scoped down.
- **It runs the same code as the server API.** Values are bound, never pasted into SQL, and an update or delete without a filter is refused. The [filter grammar](https://docs.fortly.com/data/server-api#filters) is the same.

## Get a key

In the console, open the project's **Data** tab, choose **Access**, then **Issue a key**. The same panel shows the **Base URL** your frontend calls, which is your project's address followed by `/_data`.

**Regenerate** replaces the key. The old key stops working at that moment, so pages still using it get `401` until you deploy the new one. A project has one key at a time.

From the API, where `my-app-ada` is the project's full name (the name you chose plus your account's slug, see [Project names in paths](https://docs.fortly.com/api#project-names-in-paths)):

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/api/key \
  -H "Authorization: Bearer $FORTLY_TOKEN"
```

```json
{ "key": "cvp_…", "hint": "cvp_3f9a1c2b…7d0e", "replaced_any_previous": true }
```

> **Note:**
>
> The key is meant to be public. It travels in your pages and anyone can read it. The table switches below are the only protection, so set them as if every visitor holds the key.

## Open a table

On the **Access** panel, each table has four switches: **read**, **create**, **update** and **delete**. Each HTTP method needs its own switch:

| Method | Switch it needs |
| --- | --- |
| `GET /_data/{table}` | read |
| `POST /_data/{table}` | create |
| `PATCH /_data/{table}` | update |
| `DELETE /_data/{table}` | delete |

A read that embeds a related table, such as `select=id,title,authors(name)`, needs **read** on the related table too. Otherwise a closed table could be reached through an open one.

From the API, set one switch per request:

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/api \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"table": "posts", "op": "read", "allowed": true}'
```

`op` is `read`, `create`, `update` or `delete`, and the response is `204`. `GET /projects/{name}/api` returns the base URL, the switches for every table and the current key. See the [API reference](https://docs.fortly.com/api/data).

## Make requests

- `GET /_data/{table}` — Read rows, with filters, columns, related rows, order, paging and counts
- `POST /_data/{table}` — Add a row, or an array of rows. The stored rows come back
- `PATCH /_data/{table}` — Change the rows a filter matches
- `DELETE /_data/{table}` — Remove the rows a filter matches
- `POST /_data/rpc/{routine}` — Call a routine you've opened to the key

Send the key as a bearer token. In these examples, `DATA_URL` is the **Base URL** from the **Access** panel:

**curl**

```bash
export DATA_URL="https://your-project-address/_data"   # Data › Access › Base URL
export FORTLY_PUBLIC_KEY=cvp_...

# the 20 newest published posts, with their authors
curl "$DATA_URL/posts?published=eq.true&select=id,title,created_at,authors(name)&order=created_at.desc&limit=20" \
  -H "Authorization: Bearer $FORTLY_PUBLIC_KEY"

# add a comment (needs "create" on comments)
curl -X POST "$DATA_URL/comments" \
  -H "Authorization: Bearer $FORTLY_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"post_id": 12, "body": "Great write-up."}'
```

**JavaScript**

```js
const DATA_URL = "https://your-project-address/_data"; // Data › Access › Base URL
const KEY = "cvp_..."; // publishable, so it is fine in client code

async function data(path, init = {}) {
  const res = await fetch(`${DATA_URL}/${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${KEY}`,
      ...(init.body ? { "Content-Type": "application/json" } : {}),
      ...init.headers,
    },
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error);
  return body;
}

// the 20 newest published posts, with their authors
const posts = await data(
  "posts?" +
    new URLSearchParams({
      published: "eq.true",
      select: "id,title,created_at,authors(name)",
      order: "created_at.desc",
      limit: "20",
    }),
);

// add a comment (needs "create" on comments)
const comment = await data("comments", {
  method: "POST",
  body: JSON.stringify({ post_id: 12, body: "Great write-up." }),
});
```

The **Access** panel shows these calls written out for each of your tables, with your real key, ready to copy.

Requests and responses work the same as on the [server data API](https://docs.fortly.com/data/server-api):

- **Reads** take the same [filters](https://docs.fortly.com/data/server-api#filters), `select` with [related rows](https://docs.fortly.com/data/server-api#choose-columns-and-related-rows), `order`, `limit` (100 by default, 1000 at most) and `offset`, and return a JSON array.
- **Creates** take one object or an array. An array is one transaction, and the stored rows come back with their generated keys and defaults. `Prefer: resolution=merge-duplicates` turns a primary-key clash into an update. See [Upsert](https://docs.fortly.com/data/server-api#upsert) and the CORS note below.
- **Updates and deletes** need a filter, and return `{"updated": n}` or `{"deleted": n}`.
- **Bodies** must be JSON and no larger than 1 MB.

### Count the matching rows

Add `count=exact` to a read, and the response includes the total number of rows your filters match, ignoring `limit` and `offset`, in a `Content-Range` header. The body is still the array of rows:

```http
GET /_data/posts?published=eq.true&limit=20&count=exact

HTTP/1.1 200 OK
Content-Range: */148
```

```js
const res = await fetch(`${DATA_URL}/posts?published=eq.true&limit=20&count=exact`, {
  headers: { Authorization: `Bearer ${KEY}` },
});
const total = Number(res.headers.get("Content-Range")?.split("/")[1]);
const rows = await res.json();
```

The header is exposed to browsers, so cross-origin pages can read it.

### Call a routine

A routine runs whatever SQL it was written with, so each one is closed to the key until you open it. Open it with the API, using the table name `rpc:` followed by the routine's name and the operation `call`:

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/api \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"table": "rpc:popular_posts", "op": "call", "allowed": true}'
```

Then call it with `POST`, sending the arguments as a JSON object by name or an array by position:

```bash
curl -X POST "$DATA_URL/rpc/popular_posts" \
  -H "Authorization: Bearer $FORTLY_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"since": "2026-10-01"}'
```

The response has the same shape as a [server-side routine call](https://docs.fortly.com/data/server-api#call-a-routine). Only `POST` is accepted.

## CORS and where it's served

The API answers any origin, because the key and the switches decide what's allowed, not the page that asks. Every response carries:

```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: authorization,content-type
Access-Control-Expose-Headers: content-range
Access-Control-Max-Age: 86400
```

A preflight `OPTIONS` request is answered `204` without a key.

> **Warning:**
>
> `Prefer` is not in the allowed headers. A page on another origin can't send `Prefer: resolution=merge-duplicates`, because the browser blocks it at the preflight. Pages served from your project's own address are same-origin and aren't affected. From another origin, write the upsert as a routine, or send the request from server-side code, where CORS doesn't apply.

Fortly answers these requests itself, without your app's process, so the API keeps working while your app is idle and doesn't wake it. It is served on your project's address and on any verified [domain](https://docs.fortly.com/domains) connected to the project.

## What the key cannot do

The rules are per table, not per row or per user. The key can't tell one of your users from another. If you switch on **update** for `profiles`, anyone holding the key can update any profile that a filter can reach.

So:

- **Open read** on data that's public anyway: published posts, a product catalogue, opening hours.
- **Open create** where anyone may add a row, such as a contact form or a waitlist, and keep read closed on that table if the rows are private.
- **Keep update and delete closed** on any table that holds more than one person's data. Do those writes from your server through the [server data API](https://docs.fortly.com/data/server-api), behind your own sign-in check.

The key also can't run SQL, change your schema, or reach any table or routine you haven't opened.

## Errors

Errors are JSON with an `error` message, and carry the CORS headers so your page can read them:

| Status | `error` | Cause |
| --- | --- | --- |
| `401` | `send your publishable key as a bearer token` | No `Authorization: Bearer` header |
| `401` | `that key is not valid for this project` | Unknown key, a replaced key, or a key for another project |
| `403` | `this table is not open for that. Turn it on under the project's API settings.` | The switch for this method is off, on this table or on an embedded one |
| `403` | `this routine is not open to the public API. Turn it on under the project's API settings.` | The routine hasn't been opened |
| `405` | `that method is not supported here` | A method other than `GET`, `POST`, `PATCH` or `DELETE` on a table |
| `400` | `that body was too large` | The body is over 1 MB |
| `400` | `that body is not JSON: …` | The body could not be parsed |
| `400` | the reason, for example `posts has no column titel` | A bad filter, an unknown column, a missing filter on an update or delete, or an error from PostgreSQL |
| `503` | `could not check what this table allows` | Fortly could not read the switches. Try again |
