Server data API
Read and write your project's tables over HTTP from server-side code, with PostgREST-style filters, related rows, paging and upserts.
Your server-side code can read and write the project's tables with plain HTTP requests instead of a database driver. Fortly serves one query language, PostgREST's (the one behind Supabase's REST API), in three places. Which one your code calls depends on how your project runs. This page shows how to reach each one, then describes the query language they share.
Which route your code uses
New deploys run as static sites or as Workers (server frameworks that Fortly builds, such as Next.js and Nuxt). A project deployed earlier may still run as a long-running process, and it keeps that runtime until it is deployed again.
| Your project runs as | Read and write rows with | Credential |
|---|---|---|
| A Worker | The HTTP API table routes, https://api.fortly.com/projects/{name}/tables/{table}/rows | An API token, kept in a runtime secret |
| A Worker, for tables you've opened publicly | The frontend data API, /_data/{table} on the project's own address | The publishable key |
| A long-running process | The loopback API, $FORTLY_API/{table}, or a direct connection through DATABASE_URL | FORTLY_TOKEN, set by Fortly |
| Anywhere else: a script, CI, your laptop | The HTTP API table routes | An API token |
A Worker has no DATABASE_URL and no loopback API. Fortly gives it only the project's own runtime values (see Environment and secrets), so it reaches its data over HTTPS like any other client.
From a Worker
Make an API token in the console under Settings › API tokens, and store it as a runtime value under a name of your own, such as FORTLY_API_TOKEN. FORTLY_TOKEN is reserved and can't be used. A Worker receives the runtime values whose names existed when it was built, so deploy again after you add the name.
An API token acts as your account. It can change any project you can reach, and delete one. Keep it in a runtime value, never in a build value or a NEXT_PUBLIC_ variable, and never send it to a browser. If a page only needs data you're happy to make public, use the frontend data API with the publishable key instead.
Then call the table routes from your server code. In a Next.js route handler:
const BASE = "https://api.fortly.com/projects/my-app-ada/tables";
export async function GET() {
const params = new URLSearchParams({
status: "eq.paid",
select: "id,total,created_at,customers(name)",
order: "created_at.desc",
limit: "20",
});
const res = await fetch(`${BASE}/orders/rows?${params}`, {
headers: { Authorization: `Bearer ${process.env.FORTLY_API_TOKEN}` },
});
const body = await res.json();
if (!res.ok) return Response.json({ error: body.error }, { status: 502 });
return Response.json(body.rows);
}my-app-ada is the project's full name: the name you chose plus your account's slug. See Project names in paths.
/ projects/ {name}/ tables/ {table}/ rowsRead rows. The answer is an object with rows, limit, offset and total/ projects/ {name}/ tables/ {table}/ rowsAdd a row, or an array of rows in one transaction. Answers 201 with the stored rows/ projects/ {name}/ tables/ {table}/ rowsChange the rows a filter matches. A filter is required/ projects/ {name}/ tables/ {table}/ rowsRemove the rows a filter matches. A filter is required/ projects/ {name}/ routines/ {routine}/ callCall a function or procedureThese routes take the same filters, select and order as the other two. They differ in a few ways:
- A read returns
{"rows": [...], "limit": 50, "offset": 0, "total": 148}. It always includes the total matching your filters. limitdefaults to 50 and is clamped to between 1 and 200.- An insert answers
201. There is no upsert on these routes. - A routine call returns up to 500 rows.
Every route is listed in the API reference.
From a long-running process
A project that runs as a process has two more ways in. Fortly starts the process with these variables:
| Variable | What it holds |
|---|---|
FORTLY_API | The loopback API's base URL, including /v1, for example http://127.0.0.1:9100/v1. Read it from the environment rather than hard-coding the port |
FORTLY_TOKEN | The project's own token: 48 hex characters, no prefix. It works only on this loopback API, and only for its own project |
DATABASE_URL and the PG* variables | A direct PostgreSQL connection. See Connect from your app |
Inside the process, FORTLY_TOKEN is the project token. It is not the fly_… account token that the HTTP API examples on this site keep in a variable of the same name.
/ v1/ {table}Read rows. The answer is a JSON array/ v1/ {table}Add a row, or an array of rows in one transaction. Upsert with a Prefer header/ v1/ {table}Change the rows a filter matches. A filter is required/ v1/ {table}Remove the rows a filter matches. A filter is required/ v1/ sqlRun create, alter and drop statements/ v1/ rpc/ {routine}Call a function or procedureconst params = new URLSearchParams({ status: "eq.paid", order: "created_at.desc", limit: "20" });
const res = await fetch(`${process.env.FORTLY_API}/orders?${params}`, {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
if (!res.ok) throw new Error((await res.json()).error);
const orders = await res.json(); // an array of rowsOn the loopback API, limit defaults to 100 and can be up to 1000. A read returns a JSON array and no total: count=exact is accepted but ignored. POST and PATCH must send Content-Type: application/json, or the request is refused with 415.
The loopback address can't be reached from outside Fortly, and the project token can read, change and delete any row. Keep both in server-side code.
What the routes serve
All three routes serve the ordinary tables in your database's public schema. Views and tables in other schemas aren't served. Read them with SQL in a routine, or through DATABASE_URL from a process.
The examples below use the HTTP API route, which works from every runtime. The query string is the same on all three routes.
Filters
A filter is a query parameter named after a column, whose value is an operator and a value joined by a dot:
column=operator.valuecurl "https://api.fortly.com/projects/my-app-ada/tables/orders/rows?status=eq.paid&total=gte.100" \
-H "Authorization: Bearer $FORTLY_TOKEN"| Operator | Meaning | Example |
|---|---|---|
eq | equals | status=eq.paid |
neq | does not equal | status=neq.cancelled |
gt | greater than | total=gt.100 |
gte | greater than or equal | created_at=gte.2026-10-01 |
lt | less than | total=lt.20 |
lte | less than or equal | created_at=lte.2026-10-31T23:59:59Z |
like | matches a pattern, case-sensitive | email=like.*@example.com |
ilike | matches a pattern, ignoring case | name=ilike.*lovelace* |
in | is one of a list | id=in.(1,2,3) |
is | is null, true or false | shipped_at=is.null |
fts | contains these words (see full-text search) | body=fts.fast delivery |
not. | negates any operator above | status=not.in.(cancelled,refunded) |
How filters behave:
- Every condition must match. Filters on different columns are combined with
and. There is noorparameter. - One filter per column. Each column takes a single filter per request. If you repeat a column, the filters aren't combined. For a range or an
or, write a routine and call it. - Values are bound, never pasted into SQL. Each value is sent as a parameter and cast to the column's own type.
total=gte.100compares numbers, andcreated_at=gte.2026-10-01compares timestamps. *is the wildcard inlikeandilike, standing for any run of characters. It's a literal*in every other operator.intakes a comma-separated list, with or without the brackets. A value inside the list can't contain a comma.istakes onlynull,trueorfalse.not.is.nullmeansis not null.- URL-encode values that contain spaces or reserved characters.
URLSearchParamsdoes it for you. - Columns are checked against the schema. An unknown column is refused with
orders has no column totl, and an unknown operator withunknown operator eqq.
The names select, order, limit, offset and count are reserved for the parameters below, so they're never read as column filters.
Full-text search
fts behaves differently depending on the table:
- When the table has a
tsvectorcolumn, and you filter on a text column, Fortly searches thattsvectorwithplainto_tsquery. This is a real full-text search that uses the index and matches words rather than substrings. It covers every column thetsvectorwas built from, not only the one you named. - Otherwise,
ftsis a case-sensitive substring match on the column you named (column::text like '%value%'). It finds the same rows on a small table but scans every row on a large one.
To make a table searchable, send POST /projects/{name}/tables/{table}/search with the columns to cover, for example {"columns": ["title", "body"]}. Fortly adds a generated tsvector column named after the table (posts_search) using English stemming, plus a GIN index on it. Run it again with other columns to change what's searched. DELETE on the same route removes the column and its index and leaves your rows alone. Only the account owner or an admin can do either. See the API reference.
Adding the generated column rewrites the table, and it holds a lock while it does. On a large table, do it at a quiet time.
Choose columns and related rows
select lists the columns you want, separated by commas. Without it, every column comes back:
GET …/orders?select=id,total,status&status=eq.paidTo include rows from a related table, name that table with its columns in brackets. Use * for all of its columns:
GET …/orders?select=id,total,customers(name,email) each order with its customer
GET …/customers?select=id,name,orders(id,total,status) each customer with all of their orders[
{ "id": 42, "total": 129.50, "customers": { "name": "Ada Lovelace", "email": "ada@example.com" } }
]The related rows come back under the related table's name:
- An object (or
null) when the row you asked for holds the foreign key. Each order points at one customer. - An array when the related table holds the foreign key. Each customer has many orders, and a customer with none gets
[].
The relationship comes only from a declared foreign key, such as orders.customer_id references customers(id). Fortly never guesses a link from a column's name. If there's no foreign key between the two tables, the request fails with orders and products are not related — an embedded table needs a declared foreign key between them.
You can embed several tables in one select, but only one level deep: an embedded table can't embed another one. Fortly fetches the key columns it needs to match rows and removes them from the answer unless you asked for them.
Sort and page
| Parameter | Format |
|---|---|
order | column, column.asc or column.desc, with an optional .nullsfirst or .nullslast. Separate several with commas: order=status.asc,created_at.desc.nullslast. Without it, rows come back in the database's own order, which isn't guaranteed |
limit | Rows to return. The default and maximum depend on the route: 50 and 200 on the HTTP API, 100 and 1000 on the loopback and frontend APIs |
offset | Rows to skip, 0 or more. Defaults to 0 |
count | exact asks for the total that matches your filters. The frontend data API returns it in a Content-Range header, and the HTTP API always includes it as total. The loopback API ignores it |
GET …/orders?order=created_at.desc&limit=50&offset=100 page 3, 50 at a time, newest firstAdd rows
Send one JSON object to add one row. The answer is the row as the database stored it, with defaults and generated keys filled in:
curl -X POST https://api.fortly.com/projects/my-app-ada/tables/orders/rows \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"customer_id": 7, "total": 129.50, "status": "pending"}'const res = await fetch("https://api.fortly.com/projects/my-app-ada/tables/orders/rows", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORTLY_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ customer_id: 7, total: 129.5, status: "pending" }),
});
const order = await res.json(); // includes the new id and created_at{ "id": 43, "customer_id": 7, "total": 129.50, "status": "pending", "created_at": "2026-10-12T10:20:31.84+00:00" }Send an array to add several rows. They're inserted in one transaction, so if one row fails, none are added, and the answer is an array of the stored rows:
[{ "email": "ada@example.com", "name": "Ada Lovelace" }, { "email": "grace@example.com", "name": "Grace Hopper" }]Columns you leave out get their default, or null. PostgreSQL converts each value to the column's type, so numeric, timestamptz, uuid, arrays and jsonb all accept their usual JSON forms. A key that isn't a column is refused with customers has no column nmae.
Upsert
On the loopback and frontend APIs, add Prefer: resolution=merge-duplicates to turn a clash on the primary key into an update of the existing row:
curl -X POST "$FORTLY_API/customers" \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates" \
-d '{"id": 7, "name": "Ada King"}'- The match is on the table's primary key only. A table without one is refused with
customers has no primary key, so there is nothing for an upsert to match on. - Only the columns you sent are updated. The others keep their values.
- If you send only primary-key columns, an existing row is left as it is and isn't returned. For a single row, the answer is then
{}.
The HTTP API table routes don't read Prefer. A clash there is an error.
Change rows
PATCH with a filter and a JSON object of the columns to change. Columns you don't send keep their values:
curl -X PATCH "https://api.fortly.com/projects/my-app-ada/tables/orders/rows?id=eq.43" \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "paid"}'{ "updated": 1 }Remove rows
DELETE with a filter:
curl -X DELETE "https://api.fortly.com/projects/my-app-ada/tables/orders/rows?status=eq.cancelled&created_at=lt.2026-01-01" \
-H "Authorization: Bearer $FORTLY_TOKEN"{ "deleted": 12 }An update or delete without a filter is refused, with an update needs a filter or a delete needs a filter, because it would rewrite or empty the whole table. To clear a table on purpose, use a filter that matches every row, such as id=not.is.null.
Run schema changes
Schema changes go through a separate SQL route that accepts only statements starting with create, alter or drop. From anywhere, it's POST /projects/{name}/sql. See Change the schema. From a long-running process, POST $FORTLY_API/sql takes the SQL as the raw request body:
curl -X POST "$FORTLY_API/sql" \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary 'create index orders_customer_id on orders (customer_id)'{ "ok": true, "tables": ["customers", "orders"] }The data APIs read the new shape straight away, so the next request can use the new table or column.
Call a routine
A routine is a function or procedure stored in your database. Call it with the arguments as a JSON object by parameter name, or as an array by position. Parameters you leave out use their declared defaults:
curl -X POST https://api.fortly.com/projects/my-app-ada/routines/revenue_between/call \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"since": "2026-10-01", "until": "2026-10-31"}'{
"routine": "revenue_between",
"kind": "function",
"columns": ["day", "revenue"],
"rows": [{ "day": "2026-10-01", "revenue": 1840.50 }],
"truncated": false,
"took_ms": 4
}The same call is POST $FORTLY_API/rpc/revenue_between from a long-running process, and POST /_data/rpc/revenue_between from a browser once you've opened the routine. A function returns its rows, up to 500 on the HTTP API and 1000 on the other two, with truncated set to true when there were more. A procedure that returns nothing answers with empty columns and rows and a changed count. Each argument is cast to the parameter's declared type.
A routine is how you run a query that filters can't express, such as an or, a range on one column, an aggregate or a join.
How values come back
PostgreSQL renders the values, so numeric keeps its digits, timestamptz carries its zone, and jsonb and arrays come back as JSON.
A JavaScript number holds an integer exactly only up to 9,007,199,254,740,991, and a decimal to about 15 significant digits. A number in a row beyond that, such as a large bigint or a long numeric, arrives as a string holding its exact digits, so JSON.parse can't round it without telling you. Numbers inside a jsonb document come back exactly as stored.
Errors
Errors are JSON with an error message:
{ "error": "orders has no column totl" }A request the query language can't use, such as an unknown table or column, a malformed filter, or an update or delete without a filter, answers 400. So does any error from PostgreSQL itself, such as a constraint violation, in the database's own words. Each route reports authentication its own way:
| Route | Missing or wrong credential |
|---|---|
| HTTP API table routes | 401, as described in Authentication. A project you can't access is 404, no project called {name} |
| Loopback API | 401, unknown or missing token |
| Frontend data API | 401 or 403. See Errors |