Data API
Read a project's schema, read and write rows, change tables, run SQL, import a dump and export the database, from outside the project.
Every project has its own PostgreSQL database. These routes manage it from outside the project with your account token — for scripts, CI and admin tools. Code running inside the project uses the server data API on loopback instead, and a browser uses the frontend data API with the publishable key.
/ projects/ {name}/ tablesTables, their columns and foreign keys/ projects/ {name}/ viewsViews and their definitions/ projects/ {name}/ indexesIndexes you created/ projects/ {name}/ tables/ {table}/ rowsRead a page of rows/ projects/ {name}/ tables/ {table}/ rowsInsert a row, or several/ projects/ {name}/ tables/ {table}/ rowsUpdate rows matching a filter/ projects/ {name}/ tables/ {table}/ rowsDelete rows matching a filter/ projects/ {name}/ sqlRun schema changes: create, alter, drop/ projects/ {name}/ queryRun one SQL statement and get its result/ projects/ {name}/ batchRun several statements in one transaction/ projects/ {name}/ importImport a SQL script/ projects/ {name}/ exportDownload the whole database/ projects/ {name}/ backupDatabase size and export formatsA table name that is not in the schema is refused with 400 and no table named {table}; table and column names are checked against the schema before they reach SQL, and values are always bound.
List tables
/ projects/ {name}/ tablesTables, their columns and foreign keysThe tables in the public schema, read from the database itself.
namestringRequiredname.curl https://api.fortly.com/projects/my-app-ada/tables \
-H "Authorization: Bearer $FORTLY_TOKEN"const res = await fetch("https://api.fortly.com/projects/my-app-ada/tables", {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
const { tables } = await res.json();{
"tables": [
{
"name": "orders",
"columns": [
{ "name": "id", "type": "integer", "not_null": true, "primary_key": true, "default_value": null },
{ "name": "customer_id", "type": "integer", "not_null": true, "primary_key": false, "default_value": null },
{ "name": "total", "type": "numeric", "not_null": false, "primary_key": false, "default_value": null },
{ "name": "created", "type": "timestamp with time zone", "not_null": true, "primary_key": false, "default_value": "now()" }
],
"links": [
{ "column": "customer_id", "table": "customers", "to": "id", "on_delete": "CASCADE", "on_update": "" }
]
}
]
}links are the table's declared foreign keys: column in this table points at column to of table. on_delete and on_update are the SQL action — CASCADE, SET NULL or SET DEFAULT — or empty for NO ACTION and RESTRICT, which behave as an omitted clause does.
List views
/ projects/ {name}/ viewsViews and their definitionsViews and materialized views in the public schema, with their definitions as PostgreSQL prints them.
curl https://api.fortly.com/projects/my-app-ada/views \
-H "Authorization: Bearer $FORTLY_TOKEN"const res = await fetch("https://api.fortly.com/projects/my-app-ada/views", {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
const { views } = await res.json();{
"views": [
{ "name": "paid_orders", "sql": " SELECT id,\n total\n FROM orders\n WHERE status = 'paid'::text;" }
]
}List indexes
/ projects/ {name}/ indexesIndexes you createdIndexes in the public schema that you created. The indexes PostgreSQL builds for primary keys and unique constraints are left out.
curl https://api.fortly.com/projects/my-app-ada/indexes \
-H "Authorization: Bearer $FORTLY_TOKEN"const res = await fetch("https://api.fortly.com/projects/my-app-ada/indexes", {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
const { indexes } = await res.json();{
"indexes": [
{ "name": "orders_created_idx", "table": "orders", "unique": false, "columns": ["created"] }
]
}columns holds each indexed column or expression, so a functional index reads like ["lower(email)"].
Read rows
/ projects/ {name}/ tables/ {table}/ rowsRead a page of rowsA page of rows, with the total that match. Filtering, sorting, column selection and embedding related tables use the same query grammar as the server data API.
namestringRequiredname.tablestringRequiredlimitinteger50; values are clamped to 1–200.offsetinteger0.orderstringcolumn, column.asc or column.desc, optionally followed by .nullsfirst or .nullslast. Comma-separate several.selectstringid,total. A related table in parentheses embeds it through a declared foreign key: id,customers(name).{column}stringcolumn=op.value: status=eq.paid, total=gte.100, id=in.(1,2,3), archived=is.null. Prefix not. to negate. Every filter must match.curl "https://api.fortly.com/projects/my-app-ada/tables/orders/rows?status=eq.paid&order=created.desc&limit=20" \
-H "Authorization: Bearer $FORTLY_TOKEN"const q = new URLSearchParams({ status: "eq.paid", order: "created.desc", limit: "20" });
const res = await fetch(`https://api.fortly.com/projects/my-app-ada/tables/orders/rows?${q}`, {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
const { rows, total } = await res.json();{
"rows": [
{ "id": 42, "customer_id": 7, "total": 129.5, "status": "paid", "created": "2026-10-11T14:02:11.482+00:00" }
],
"limit": 20,
"offset": 0,
"total": 1
}total is the number of rows matching the filters, ignoring limit and offset. A filter on a column that does not exist, an unknown operator or a malformed value answers 400 with the reason.
Insert rows
/ projects/ {name}/ tables/ {table}/ rowsInsert a row, or severalThe body is one JSON object for one row, or an array of objects for several. Several rows are inserted in one transaction: all of them or none. Columns you leave out get their default, so an identity primary key fills itself in.
namestringRequiredname.tablestringRequiredcurl -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.5,"status":"paid"}'const res = await fetch("https://api.fortly.com/projects/my-app-ada/tables/orders/rows", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORTLY_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ customer_id: 7, total: 129.5, status: "paid" }),
});
const row = await res.json();Answers 201 with the row as the database stored it, every column included — an object for an object, an array for an array:
{ "id": 43, "customer_id": 7, "total": 129.5, "status": "paid", "created": "2026-10-12T09:30:00.117+00:00" }A constraint the database enforces — a duplicate key, a missing not null value, a foreign key with nothing at the other end — answers 400 with the database's message. An empty array answers 400 with nothing to insert.
Update rows
/ projects/ {name}/ tables/ {table}/ rowsUpdate rows matching a filterSets the columns in the body on every row the filters match. Columns not in the body keep their values.
namestringRequiredname.tablestringRequired{column}stringRequiredan update needs a filter, because it would rewrite the whole table.The body is a JSON object of column names and new values. A name that is not a column of the table answers 400.
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":"refunded"}'const res = await fetch("https://api.fortly.com/projects/my-app-ada/tables/orders/rows?id=eq.43", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.FORTLY_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status: "refunded" }),
});
const { updated } = await res.json();{ "updated": 1 }Delete rows
/ projects/ {name}/ tables/ {table}/ rowsDelete rows matching a filterDeletes every row the filters match.
namestringRequiredname.tablestringRequired{column}stringRequireda delete needs a filter.curl -X DELETE "https://api.fortly.com/projects/my-app-ada/tables/orders/rows?status=eq.cancelled" \
-H "Authorization: Bearer $FORTLY_TOKEN"const res = await fetch(
"https://api.fortly.com/projects/my-app-ada/tables/orders/rows?status=eq.cancelled",
{ method: "DELETE", headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` } },
);
const { deleted } = await res.json();{ "deleted": 3 }Change the schema
/ projects/ {name}/ sqlRun schema changes: create, alter, dropRuns migrations. The SQL may hold several statements, and every one of them must start with create, alter or drop; anything else refuses the whole request before any of it runs. Read and write rows through the routes above, or use Run a query.
namestringRequiredname.sqlstringRequiredThe SQL reaches PostgreSQL as written. id integer primary key means a column you must fill in yourself; use integer generated by default as identity primary key for one that fills itself in.
curl -X POST https://api.fortly.com/projects/my-app-ada/sql \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"sql":"create table customers (id integer generated by default as identity primary key, name text not null); create index customers_name_idx on customers (name);"}'const sql = `
create table customers (
id integer generated by default as identity primary key,
name text not null
);
create index customers_name_idx on customers (name);
`;
const res = await fetch("https://api.fortly.com/projects/my-app-ada/sql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORTLY_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ sql }),
});
const { tables } = await res.json();Answers with every table now in the database:
{ "ok": true, "tables": ["customers", "orders"] }A statement of another kind answers 400 with only create, alter and drop are accepted here; one PostgreSQL refuses answers 400 with its message.
Run a query
/ projects/ {name}/ queryRun one SQL statement and get its resultRuns one statement of any kind and returns what it produced. This is the console's query editor; use it for ad-hoc reads, reports and one-off fixes.
namestringRequiredname.sqlstringRequiredcurl -X POST https://api.fortly.com/projects/my-app-ada/query \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"sql":"select status, count(*) from orders group by status"}'const res = await fetch("https://api.fortly.com/projects/my-app-ada/query", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORTLY_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ sql: "select status, count(*) from orders group by status" }),
});
const { columns, rows, truncated } = await res.json();A statement that returns rows answers with them, at most 500. truncated is true when there were more:
{
"columns": ["status", "count"],
"rows": [
{ "status": "paid", "count": 118 },
{ "status": "refunded", "count": 4 }
],
"truncated": false,
"took_ms": 3
}A statement that returns no rows answers with how many it changed:
{ "columns": [], "rows": [], "changed": 12, "took_ms": 5 }A statement that runs longer than 15 seconds answers 400 with that query took too long and was stopped. A result with two columns of the same name is refused; alias one of them. An empty sql answers 400 with there is nothing to run.
Run a batch
/ projects/ {name}/ batchRun several statements in one transactionRuns up to 200 statements in order, in one transaction. If any fails, none of them is applied.
namestringRequiredname.statementsstring[]Requiredcurl -X POST https://api.fortly.com/projects/my-app-ada/batch \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"statements":["alter table orders add column note text","update orders set note = '\''imported'\'' where note is null"]}'const res = await fetch("https://api.fortly.com/projects/my-app-ada/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORTLY_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
statements: [
"alter table orders add column note text",
"update orders set note = 'imported' where note is null",
],
}),
});
const { results } = await res.json();results holds one query result per statement, in order:
{
"results": [
{ "columns": [], "rows": [], "changed": 0, "took_ms": 4 },
{ "columns": [], "rows": [], "changed": 122, "took_ms": 9 }
]
}A failing statement answers 400 naming its position, such as statement 2: …. No statements answers 400 with there is nothing to run; more than 200 with at most 200 statements in one batch.
Import a SQL script
/ projects/ {name}/ importImport a SQL scriptRuns a SQL script — a dump from any PostgreSQL, including a Fortly export — against the database as it is. Nothing is emptied first. The whole script runs in one transaction, so a script that stops partway leaves the database exactly as it was. See Importing a database for preparing a dump.
Only the account owner or a team admin can import; a member gets 403.
namestringRequiredname.The body is the script itself, as UTF-8 text, up to 256 MB. Lines that only make sense to psql or to a session are skipped: backslash commands, SET (but not SET CONSTRAINTS), select pg_catalog.set_config(…), and the dump's own BEGIN, COMMIT, END and ROLLBACK. Everything else runs, including GRANT, COMMENT ON and ALTER TABLE.
curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @dump.sqlimport { readFile } from "node:fs/promises";
const res = await fetch("https://api.fortly.com/projects/my-app-ada/import", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORTLY_TOKEN}`,
"Content-Type": "application/octet-stream",
},
body: await readFile("dump.sql"),
});
const { ran, skipped, tables } = await res.json();{ "imported": "statements", "ran": 1284, "skipped": 9, "tables": 14 }ran is statements sent to the database, skipped the lines left out as above, and tables the number of tables afterwards.
| Status | When |
|---|---|
400 | The file is empty, binary (such as a SQLite database file) or not UTF-8 text, or a statement was refused. The error names the statement and how many ran before it; none of them is kept. |
403 | You are a member, not the owner or an admin. |
413 | The file is over 256 MB. |
Export the database
/ projects/ {name}/ exportDownload the whole databaseThe whole database as a file: schema and data.
namestringRequiredname.formatstringpostgres (default; also postgresql, pg), sql (the same dialect), mysql (also mariadb), or json.gzipstring1, true or yes to compress it.curl -OJ "https://api.fortly.com/projects/my-app-ada/export?format=postgres&gzip=1" \
-H "Authorization: Bearer $FORTLY_TOKEN"import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.fortly.com/projects/my-app-ada/export?format=postgres", {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
const name = /filename="([^"]+)"/.exec(res.headers.get("content-disposition"))[1];
await writeFile(name, Buffer.from(await res.arrayBuffer()));The response body is the file, not JSON:
HTTP/1.1 200 OK
Content-Type: application/sql
Content-Disposition: attachment; filename="my-app-ada-2026-10-12.sql"Content-Type is application/sql for the SQL formats, application/json for json, and application/gzip when compressed, in which case the filename ends in .gz. An unknown format answers 400.
Read backup status
/ projects/ {name}/ backupDatabase size and export formatsWhat can be taken from this database, and how big it is.
curl https://api.fortly.com/projects/my-app-ada/backup \
-H "Authorization: Bearer $FORTLY_TOKEN"const res = await fetch("https://api.fortly.com/projects/my-app-ada/backup", {
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
});
const status = await res.json();{
"automatic": false,
"size_bytes": 8028160,
"formats": ["sql", "postgres", "mysql", "json"]
}automatic is false: no continuous copy of the database is taken, so export is how you keep one. See Backups.