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.
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 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):
curl -X POST https://api.fortly.com/projects/my-app-ada/api/key \
-H "Authorization: Bearer $FORTLY_TOKEN"{ "key": "cvp_…", "hint": "cvp_3f9a1c2b…7d0e", "replaced_any_previous": true }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:
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.
Make requests
/ _data/ {table}Read rows, with filters, columns, related rows, order, paging and counts/ _data/ {table}Add a row, or an array of rows. The stored rows come back/ _data/ {table}Change the rows a filter matches/ _data/ {table}Remove the rows a filter matches/ _data/ rpc/ {routine}Call a routine you've opened to the keySend the key as a bearer token. In these examples, DATA_URL is the Base URL from the Access panel:
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."}'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:
- Reads take the same filters,
selectwith related rows,order,limit(100 by default, 1000 at most) andoffset, 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-duplicatesturns a primary-key clash into an update. See 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:
GET /_data/posts?published=eq.true&limit=20&count=exact
HTTP/1.1 200 OK
Content-Range: */148const 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:
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:
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. 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:
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: 86400A preflight OPTIONS request is answered 204 without a key.
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 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, 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 |