R RowFold API v1
← Back to Developers

RowFold API

A clean REST API over everything in a workspace — tables, fields, records, the audit log, and webhooks. JSON in, JSON out, over HTTPS. This page is live: authorize with a client ID and secret and run any request against your own workspace, right here.

Every request goes to:

https://www.rowfold.com/api/v1
New here? Create an API client on the Developers page, enter its ID and secret below to authorize, then press Send request on any endpoint. Read-only calls are safe to run; writes are clearly marked and act on real data.

Prefer to generate a client? The full contract is published as an OpenAPI 3.1 document — point Postman, Insomnia, openapi-generator or your editor at https://www.rowfold.com/api/v1/openapi.json. No credentials needed to read it.

Authorize

Enter an API client’s ID and secret to power the Try it console. They’re exchanged for a short-lived access token, kept only in this browser tab (session storage) and sent only to this API.

Authentication

The API uses the OAuth2 client-credentials flow. A workspace Admin creates an API client on the Developers page and gets two things: a clientId (public, rfc_…) and a clientSecret (shown once, rfsk_…). The client is your durable credential — it never rides on API calls.

Instead, exchange it for a short-lived access token at POST /api/v1/auth/token:

curl -X POST https://www.rowfold.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"clientId":"rfc_…","clientSecret":"rfsk_…"}'

# → { "accessToken": "eyJ…", "tokenType": "Bearer", "expiresIn": 3600, "scope": "read write" }

Then send that token as a bearer token in the Authorization header (an X-Api-Key header works too) on every other call. It’s a signed JWT whose claims carry the identity checked offline on each request: the workspace it opens, its scopes, and its expiry.

curl https://www.rowfold.com/api/v1/meta \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Access tokens live about an hour (expiresIn is the exact seconds). When one expires the API returns 401 — just call /auth/token again to get a fresh one. Cache the token for its lifetime rather than exchanging on every request.

Scopes

Each client carries one or more scopes; the tokens it mints inherit them, and endpoints require the matching one:

  • read — every GET (tables, records, audit).
  • write — create, update, delete, and restore records.
  • schema — create tables and fields, and manage webhooks.

Expiry & revocation

A client can be set to expire (30 / 90 / 365 days) or never, and can be revoked instantly from the Developers page. A revoked or expired client can no longer mint tokens, and any access token it already issued stops working within the hour as it expires on its own. The secret is shown once at creation and stored only as a hash — it can be revoked but never re-displayed. Keep the secret server-side; never ship it in client code.

Errors & conventions

Errors always use one envelope, with a matching HTTP status:

{ "error": { "code": "missing_scope", "message": "This API token doesn't have the write scope." } }
StatusMeaning
200 / 201Success.
400Validation failed — error.details names the fields.
401Missing, invalid, or expired access token — or, on /auth/token, bad client credentials (invalid_client).
402The workspace’s free trial has ended.
403The token lacks the required scope.
404Not found in this token’s workspace.
409State conflict — the record is posted (locked), the field is approval-protected, or that approver was already asked. See Posted records & approvals.
429Rate limited — see Retry-After.
  • All JSON is camelCase. Timestamps are UTC ISO-8601 (field names end in Utc).
  • Record values are keyed by field key (a stable slug — list them with GET /tables).
  • Relation values are an array of record ids, and writing one replaces that field's links rather than adding to them — send the full list you want the record to end up with. A single id string is accepted as shorthand for a one-element array; [] or null unlinks everything. Every id must be a record that exists (and isn't in the Trash) in the relation's target table: an unknown, trashed, or wrong-table id fails the whole write with a 400 whose error.details names the offending ids — nothing is partially applied. A single-link relation takes exactly one id. Any other shape is also a 400. To find ids to link, search the related table with GET /tables/{tableId}/records?q=.
  • Writing computed fields — Lookup, Rollup, Formula, AutoNumber and Attachment — is refused with a 400; they change only through the mechanism that owns them. Lookup/Rollup/Formula are computed at read time and never appear in values at all — the schema marks such fields "computed": true, and their text arrives in display (next bullet).
  • Reading: values is raw, display is human. Records from GET /tables/{tableId}/records and GET /records/{id} carry a display object alongside values — field key → display text for everything raw values can't say: computed Lookup/Rollup/Formula results, and Relation fields as the linked records' names joined with ", " (the id array stays in values, unchanged). Write with values; render with display. Write responses echo the record without display.
  • display stays unformatted, and the schema tells you how the app formats it. A Formula or Rollup can declare how it is presented in RowFold — money, a percentage, a progress bar, hours and minutes. That declaration rides on the field as "displayFormat" (currency, number, percent, progress or duration) and "displayPrecision" (decimal places, or null for automatic); both are null on every other field, whose type already implies its presentation. The value in display is deliberately left as the plain number — "1234.5", never "£1,234.50" — so it stays parseable and locale-independent. Format it yourself from these two values plus your own locale; the reverse is not recoverable.
  • Deletes are soft: DELETE moves a record to the Trash; restore brings it back.
  • API writes appear in the workspace audit log as API · token-name, and they fire automations and webhooks exactly like edits made in the app.

Rate limits

Each token is limited to 300 requests per minute (a sliding window). Over the limit returns 429 with a Retry-After header and the standard error envelope. Requests with no token are limited far more tightly by IP. The token-exchange endpoint (/auth/token) has its own tighter per-IP limit — cache your access token for its lifetime rather than re-exchanging on every call.

Pagination, sorting & filtering

List endpoints take pageSize (max 200) and offset. Each response includes total and the next offset (or null at the end).

Record lists also accept q (name search), sort, and a filter — a URL-encoded JSON array of conditions, combined with match=all (default) or match=any:

filter=[{"field":"status","op":"eq","value":"Active"},
        {"field":"mrr","op":"gte","value":"100"}]

Operators: eq, neq, contains, not_contains, empty, not_empty, gt, gte, lt, lte. Use __displayName to target the record name. Field filters scan up to 5,000 rows per request; the response sets scannedCap when that boundary is hit.

Conditions compare against what you'd see in display: a filter on a Relation field matches the linked records' names (not their ids — {"field":"customer","op":"contains","value":"Acme"} works), and filters on Lookup/Rollup/Formula fields compare their computed text. All other fields compare their raw stored value.

API reference

Every endpoint below has copy-ready samples and a live Try it runner. Path fields like tableId autocomplete from your workspace once you authorize.

Attachments

Attachment fields hold files. A record's values include the metadata (a JSON array of {"Id","N","S","Ct"} — id, name, size, content type); these endpoints move the bytes. Uploads: at most 10 files per field, 10 MB each; executable types are refused. AutoNumber and Attachment fields are read-only through record writes — they change only via their own mechanisms.

Address fields are plain text through the API — write any address string and it behaves like a Text field. In the app, Address fields power the Map view and autocomplete; the API needs no special handling, and Address is a creatable field type.

POST https://www.rowfold.com/api/v1/records/{recordId}/attachments/{fieldKey}
Authorization: Bearer <token>          # write scope
Content-Type: multipart/form-data      # one file part

→ 200 { "id": "…", "name": "contract.pdf", "size": 48211, "count": 1 }

GET https://www.rowfold.com/api/v1/attachments/{recordId}/{fileId}
Authorization: Bearer <token>          # read scope — streams the file back

New records can also arrive from outside your code entirely: every table supports public forms (shareable links) and inbound email addresses — both create records that flow to your webhooks like any other write. Manage them from the table's Forms & email intake page.

Posted records & approvals

Tables can carry posting rules — approval stages plus relationship gates that a record clears before it is posted. Posting is a seal: a posted record is read-only everywhere — the app, imports, automations, and this API — until someone unposts it. Every record the API returns says where it stands: postedAt and postedBy ride on every record payload, both null while the record is open.

The lock, on the wire

Writes against a posted record fail fast with 409 and a stable code — never a 500:

PATCH /records/{id}   → 409 { "error": { "code": "record_posted",
    "message": "This record is posted — it is locked until someone unposts it." } }
DELETE /records/{id}  → the same 409 — unpost first, then delete

One more refusal exists on tables with posting rules: when the policy protects its approval status field, writing that field directly returns 409 protected_field — the status only moves through the approval flow itself, so an "Approved" in that column always means a real decision. Attachments are the exception to the lock: files can still be added to a posted record — they are notes pinned to it (the signed PDF of a posted invoice), not the sealed content.

Asking for approval

POST /records/{id}/approvals raises an ad-hoc ask and GET /records/{id}/approvals reads the whole trail — see the reference above. Asks raised here appear to the approver as API · token-name, so a rule-driven ask is never mistaken for a person.

Decisions are human — on purpose

There is deliberately no endpoint to approve or reject. A machine key acts for a workspace, not a person, and the audit trail's "who approved" must always name a person — an approval a script could stamp would be worth nothing at audit time. Approvers decide in the app, from the notification bell, or through the signed one-click link in the approval email, which works without a login; your integration can watch the outcome via GET /records/{id}/approvals, or subscribe a webhook to record.updated and watch the approval status field move.

Machine posting

POST /records/{id}/post and POST /records/{id}/unpost let an integration stamp and clear the seal — an accounting sync posting invoices after reconciliation, for example. The same rules apply as in the app: the approval journey must be complete, every gate must pass, and a table whose rules reserve posting for admins refuses a machine key outright (403 admins_only). Unposting always takes a reason, which lands in the audit log. Cascade sealing — posting a record together with its linked children — is not exposed through the API in v1, in either direction; it stays a deliberate act in the app.

Receiving webhook events

A webhook subscription POSTs each matching record change to your URL. Create one with POST /webhooks (above) or on the Developers page. Events: record.created, record.updated, record.deleted, record.restored — for all tables or one.

POST https://your-endpoint.example.com/rowfold
X-RowFold-Event: record.updated
X-RowFold-Delivery: 6f0c9e0a-…
X-RowFold-Signature: t=1699999999,v1=<hex hmac>

{ "id": "6f0c9e0a-…", "event": "record.updated",
  "workspaceId": "…", "tableId": "…", "tableName": "Customers",
  "record":   { "id": "…", "displayName": "Acme Ltd", "values": { }, "display": { } },
  "previous": { "displayName": "Acme Ltd", "values": { } },
  "changedKeys": ["status", "owner"],
  "occurredAtUtc": "2026-07-19T18:00:00Z", "chainDepth": 0 }

record.values holds stored values — Relation fields as real arrays of record ids (in previous.values too). record.display is the human-readable layer: linked record names and computed Lookup/Rollup/Formula values, keyed by field key. Computed keys never appear in values; read them from display.

Verify the signature

Each delivery is signed with the hook’s secret so you can prove it came from RowFold. Recompute the HMAC over timestamp + "." + rawBody and compare to v1; reject timestamps older than five minutes to stop replays. Sign the raw bytes — parsing the JSON and re-serialising it changes them and the signature will not match.

const crypto = require("crypto");

// rawBody must be the exact bytes received, not a re-serialised object.
function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    String(header || "").split(",").map(p => p.split("=")).filter(p => p.length === 2));
  const t = parts.t, v1 = parts.v1;
  if (!t || !v1) return false;

  // Replay window first — a valid signature on a two-day-old body is still a replay.
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;

  const mac = crypto.createHmac("sha256", secret)
    .update(t + "." + rawBody).digest("hex");

  // timingSafeEqual THROWS on a length mismatch, so guard before calling it.
  const a = Buffer.from(mac, "utf8"), b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The same three steps in Python:

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() - int(t)) > tolerance:
        return False
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, v1)

Delivery guarantees

Delivery is at-least-once. Every payload carries a stable id — also sent as X-RowFold-Delivery — and a retry, or a redelivery someone triggers by hand, reuses it. Deduplicate on that id: seeing it twice means the same event, not a second one.

Retries & the delivery log

Deliveries run on a durable job queue (they survive restarts) and make up to 6 attempts — immediately, then ~1s, ~4s, ~30s, ~5min, ~30min. A 4xx (other than 408/429) means your endpoint refused the payload and is not retried; either way a delivery that runs out of road is marked dead in the log. A hook that fails 20 times in a row pauses itself. Every attempt — status, duration, error, and whether a retry is coming — is recorded in Recent webhook deliveries on the Developers page, kept for 30 days. Answer with any 2xx within 10 seconds.

Any delivery still inside that 30-day window can be re-sent by hand from the log. It replays the original bytes — the payload as it was when the change happened, not the record as it is now — under the original delivery id, with a fresh signature timestamp.

Choosing what arrives

Three controls, all on the Developers page and all available through POST /webhooks:

  • Payload shape. full (default) sends record.values, record.display and previous. minimal sends the ids and changedKeys only — for receivers that read back through the API and would rather not be handed a copy of the data.
  • Field filter. An optional condition — {"field":"status","op":"eq","value":"Won"} — evaluated against the same enriched values automations see, so Lookup, Rollup and relation labels all work. An event that does not match is never queued and never appears in the log.
  • Custom headers. Sent with every delivery, for a receiver that needs its own credential. X-RowFold-* and the framing headers (Host, Content-Type, Content-Length, …) are reserved; Authorization is not.

changedKeys is present in both shapes: the field keys this save actually moved. A create lists everything it arrived with; a delete lists nothing.

Sending events IN: webhook-triggered automations

Traffic flows the other way too. An automation with the A webhook is received trigger gets its own unguessable inbound URL (/hooks/{token}, shown in the automation editor). POST a JSON object to it — up to 128 KB — and the automation fires with the payload exposed to its steps as {{webhook.<path>}} tokens (nested values by dots, array items by index, the raw body as {{webhook.body}}). The receiver answers 202 immediately; the steps run on the same durable queue, and every fire appears in the automation's Run history. The token is the credential — rotate by recreating the automation, and note the per-IP rate limit of 120 posts/minute.

MCP for AI agents

RowFold serves the Model Context Protocol at POST /mcp (JSON-RPC 2.0 over streamable HTTP), so Claude Code, Claude Desktop, Cursor and any other MCP client can work a workspace directly. The read tools are the same twelve the in-app Ask assistant runs — list/schema, database-filtered queries and exact aggregates (including filters that travel a relation), keyword search, one-record reads, geo lookups, record history, comments, automation runs, text attachments, and CSV export tickets. Connections created with allow record writes additionally get create_records, update_records and trash_records — the same validation stack as Ask's confirmed edits (per-record field permissions, the posting seal, lifecycle verdicts; refusals are skipped with the reason), every tool takes dry_run to preview without writing, updates return each record's previous map (post it back to undo), trash is the restorable soft delete, and batches cap at 40. Writes are audited as MCP · connection (as person) and ride the normal save path, so AutoNumbers stamp and automations fire.

Authentication

Not the API's client-credentials flow — an MCP client reads one static line from a config file, so the credential is a long-lived key (rfmcp_…) sent as Authorization: Bearer on every call. Mint one under Developers → AI agents (MCP); it is shown once and stored as a hash. Every connection acts as one member: the agent reads with that person's own permissions — hidden fields stay hidden, no-access tables stay invisible, admin-gated tools follow their role — and revoking the key (or removing the member) ends it within a minute. Unknown, revoked and dead keys all answer the same 401; a workspace past its trial answers 402.

Wire-level notes

  • initialize, ping, tools/list and tools/call are supported; the server is stateless (no session ids), answers plain JSON, and GET /mcp returns 405 — there is no server-initiated stream.
  • Rate limit: 120 requests/minute per key (same sliding-window shape as the API's).
  • Tool results are JSON-as-text, capped at ~14 KB per call — narrow with filters or use aggregates, which is also what the server's instructions tell the model.
  • Relative date filters resolve in the workspace's timezone, like everywhere else in the product.

Claude Code setup is one command — the create step prints it with your key filled in:

claude mcp add rowfold --transport http https://www.rowfold.com/mcp --header "Authorization: Bearer rfmcp_…"

Automation template tokens

Anywhere an automation step takes a value — email subjects and bodies, webhook URLs, field assignments, AI prompts — these tokens render per run:

{{record.name}}   {{record.id}}   {{record.url}}   {{record.<field_key>}}
{{webhook.<path>}}   {{table.name}}   {{workspace.name}}   {{trigger.summary}}   {{now}}   {{today}}

Unknown tokens render as empty text. Field keys are the same slugs the API uses — GET /tables lists them.

Need something that isn’t here yet? The API grows with the product — tell us what you’re building.