Developers
REST API
The REST API reads, creates and updates records in your apps. Every request acts as the user who owns the API key, with exactly their permissions: the same tables, rows and fields they can see and edit in the app.
The API does not create or configure apps, tables, fields, views or automations, and it does not delete records. Use the app editor for that. It only sees the published version of an app (not the draft), and the real data (never the editor's test data).
A key can be limited when you create it (Settings → API keys): to one app, and/or to
read only (lists and reads; writes answer 403 forbidden).
#Basics
- Base URL:
https://apptown.app/api/v1 - Authentication:
Authorization: Bearer <api key>on every request. Cookies are ignored. - Requests and responses are JSON (
Content-Type: application/json), UTF-8. Request bodies are limited to 1 MB. - IDs are UUIDs. Times are RFC 3339 in UTC, for example
2026-11-03T14:05:00Z. - Call the API from a server or script, never from a browser page. Browsers block cross-site calls to it.
#Check your key
GET /api/v1/me
{
"id": "0192f0c4-…",
"email": "you@example.com",
"name": "Your Name",
"timezone": "Europe/London",
"api_key_id": "0192f0d1-…"
}
#Records
{app} is the app's ID (in the app's address: /a/{app}). {table} is the table's ID or
its API name: its name in lowercase with _ between words (Hourly rates →
hourly_rates). Field keys are the fields' API names, made the same way (a repeated
name gets _2). Renaming a table or field changes its API name; IDs never change.
| Method | Path | Does |
|---|---|---|
GET |
/apps/{app}/tables/{table}/records |
list records |
GET |
/apps/{app}/tables/{table}/records/{record} |
get one record |
POST |
/apps/{app}/tables/{table}/records |
create a record |
PUT |
/apps/{app}/tables/{table}/records/{record} |
update a record |
#A record
{
"id": "0192f0e2-…",
"table": "orders",
"version": 3,
"created_at": "2026-11-03T14:05:00Z",
"updated_at": "2026-11-03T15:20:11Z",
"fields": {
"customer": "Ada Lovelace",
"total": 120.5,
"status": "paid"
}
}
Each field type has a fixed JSON form: numbers as numbers, dates as "2026-11-03", date
and times as RFC 3339, select options by label ("paid" or the label as written), links as
the linked record's ID (a list for "many"), people as their email, prices as
{"amount": "12.50", "currency": "USD"}, files as [{"id", "name", "size", "mime"}].
Calculated fields (formulas, created at, record ID …) are included but can't be set.
#List records
GET /apps/{app}/tables/{table}/records?limit=50&cursor=…&filter=…&sort=…
limit: 1–100, default 50.cursor: thenext_cursorfrom the previous page. Leave it out for the first page.filter(optional): an appTown expression with field names, for exampleAND([Status] = "Paid", [Total] > 100).sort(optional): field API names separated by commas, pluscreated_atandupdated_at. Put-before a name to sort that field descending, for example-created_at,customer.
{ "records": [ … ], "next_cursor": "eyJpZCI6…" }
next_cursor is null on the last page. You only ever get rows your row-level security
lets you see.
#Create a record
POST /apps/{app}/tables/{table}/records with {"fields": { … }}. The response is
201 Created with the new record. Fields you leave out get their default values.
Send an Idempotency-Key header, such as a UUID you generate, so a retried request
can't create the record twice. A retry with the same key within 24 hours returns
the first response.
#Update a record
PUT /apps/{app}/tables/{table}/records/{record} with {"fields": { … }}.
- Only the fields you send change. Fields you leave out keep their values. To clear a field,
send
null. - To be safe against overwriting someone else's change, send
If-Match: "<version>"with the version you read. If the record changed since, you get412 precondition_failed. - The response is the updated record. Every record response has an
ETagheader with its version.
Creates and updates made through the API work exactly like edits in the app: they're validated the same way, kept in the record's history (marked as made by your API key), shown live to people who have the app open, and they can trigger automations.
#Errors
Every error has the same shape:
{
"error": {
"code": "validation_failed",
"message": "Please fix the listed fields.",
"fields": { "total": "Must be a number." }
}
}
fields appears only on validation_failed.
| Status | code |
Means |
|---|---|---|
| 400 | bad_request |
malformed JSON or parameters |
| 401 | unauthorized |
missing, invalid, expired or revoked API key |
| 403 | forbidden |
you can see this but aren't allowed to do that |
| 404 | not_found |
doesn't exist, or you have no access to it |
| 409 | conflict |
a request with the same Idempotency-Key is still running |
| 412 | precondition_failed |
the record changed since the version in If-Match |
| 413 | — | body over 1 MB |
| 422 | validation_failed |
field values failed validation (fields lists them) |
| 429 | rate_limited |
too many requests; wait for Retry-After seconds |
| 500 | internal |
our fault; the message includes a request ID for support |
#Limits
- 10 requests per second per key, with bursts up to 50. There is also a limit per IP address.
- Every response carries an
X-Request-Idheader. Include it when you contact support.