Docs

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.

Updated October 12, 2026
On this page
  1. How it stays safe
  2. Get a key
  3. Open a table
  4. Make requests
  5. Count the matching rows
  6. Call a routine
  7. CORS and where it's served
  8. What the key cannot do
  9. Errors

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):

Terminal
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 }

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:

MethodSwitch 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:

Terminal
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

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:

Terminal
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."}'

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, select with 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 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
JavaScript
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:

Terminal
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:

Terminal
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:

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.

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:

StatuserrorCause
401send your publishable key as a bearer tokenNo Authorization: Bearer header
401that key is not valid for this projectUnknown key, a replaced key, or a key for another project
403this 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
403this routine is not open to the public API. Turn it on under the project's API settings.The routine hasn't been opened
405that method is not supported hereA method other than GET, POST, PATCH or DELETE on a table
400that body was too largeThe body is over 1 MB
400that body is not JSON: …The body could not be parsed
400the reason, for example posts has no column titelA bad filter, an unknown column, a missing filter on an update or delete, or an error from PostgreSQL
503could not check what this table allowsFortly could not read the switches. Try again
Something missing or unclear? Tell us and we will fix the page.© 2026 Fortly