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. Every endpoint below says which it is: Read-only — safe to run, or ⚠ Runs for real against your workspace.

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. Call /auth/token again for a fresh one, and cache it 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, members, 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 mints no further tokens. Any access token it already issued keeps working until it expires on its own, within the hour. RowFold shows the secret once at creation and stores only a hash, so you can revoke it but never see it again. Keep it 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.

Conventions

  • 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.
  • 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. They fire automations and webhooks exactly like edits made in the app.

Writing a Relation field

A Relation value is 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 shorthand for a one-element array.
  • [] or null unlinks everything.
  • A single-link relation takes exactly one id.
  • Every id must name a record that exists, is not in the Trash, and belongs to the relation's target table.

An unknown, trashed, or wrong-table id fails the whole write with a 400, and error.details names the offending ids — RowFold applies nothing partially. Any other shape is also a 400. To find ids to link, search the related table with GET /tables/{tableId}/records?q=.

Writing a People field

A People value is an array of person ids, and writing one replaces who the field names, the same way a Relation write replaces its links. Name each person by id or by email address:

{ "values": { "assignee": ["ada@acme.com", "3f2a8c1e-0b4d-4e5f-9a6b-7c8d9e0f1a2b"] } }
  • An address is matched against this workspace's members, ignoring case, and stored as that member's id, so it reads back as the id.
  • GET /members lists every member's id, name and role.
  • A single string is shorthand for a one-element array.
  • [], null or an empty string clears the field.
  • Names are not matched, because two people can share one.

An id or address that doesn't belong to a member, or an address two members share, fails the whole write with a 400, and error.details names the field and what it refused. The check only looks at this workspace's members, so an address with no account and an address belonging to someone in another workspace get the same answer.

GET /members returns no email addresses, on purpose. A member's address is also how they sign in, and an API key usually lives inside another system, so the API doesn't publish the team's addresses. You don't need the list to assign somebody whose address you already have: send the address.

Fields you cannot write

Writing a Lookup, Rollup, Formula, AutoNumber, Attachment or Doc field returns a 400. Each changes only through the mechanism that owns it.

A Doc is a whole document belonging to one record, written in the app. Its values entry is only how the doc starts (about its first 280 characters, as plain text), kept for filters and search, so writing it would set a summary that disagrees with the document.

Lookup, Rollup and Formula resolve at read time, so they never appear in values at all. The schema marks them "computed": true, and their text arrives in display.

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 cannot say:

  • Computed Lookup, Rollup and Formula results.
  • Relation fields as the linked records' names, joined with ", ". The id array stays in values, unchanged.
  • People fields as the members' names, joined the same way. Somebody who has left the workspace is left out.

Write with values; render with display. Write responses echo the record without display.

Formatting a computed value

display stays unformatted, and the schema tells you how the app formats it. A Formula or Rollup can declare its presentation — money, a percentage, a progress bar, hours and minutes — and that declaration rides on the field as two properties:

  • "displayFormat" — currency, number, percent, progress or duration.
  • "displayPrecision" — decimal places, or null for automatic.

Both are null on every other field, whose type already implies its presentation. RowFold deliberately leaves the value in display 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.

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. On a date field, seven more are counted from today on the workspace's calendar: today, past (before today) and future (after today) take no value; last and next take a number of days and include today; older and later take a number of days and mean strictly more than that many days ago, or ahead. {"field":"due","op":"past"} is everything overdue. 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. You can still add files to a posted record: an attachment is a note pinned to it — the signed PDF of a posted invoice — rather than part of 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 one of three places: 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 watches the outcome instead — poll 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.

Nineteen read tools — the same ones the in-app Ask assistant runs, not a thinner set written for outsiders. They cover:

  • Schema — list_tables, get_table_schema.
  • Reads — query_records (filtered and sorted in the database, including filters that travel a relation), search_records, get_record.
  • Exact aggregates — aggregate_records counts, sums, averages, minimums and maximums in Postgres over every matching row, not over the page the model happened to fetch.
  • Saved work — list_views, list_reports, run_report.
  • Around the record — get_record_history, get_comments, get_conversation, get_ticket_stats, get_automation_runs (admins only), read_attachment.
  • Elsewhere — find_nearby, find_free_time, search_help, export_records.

Four write tools — create_records, update_records, trash_records and restore_records — appear only on connections created with allow record writes. The scope widens the toolbox; it never widens the permissions. Each one runs the same validation stack as Ask's confirmed edits, per record: field-level permissions, the posting seal, and the lifecycle verdict. A record that fails is skipped with its reason rather than failing the batch.

  • Every tool takes dry_run: full validation, nothing written.
  • update_records returns each record's previous map — post that back through the same tool to undo.
  • trash_records is the restorable soft delete, and restore_records is what makes that true. Nothing here destroys a record.
  • Batches cap at 40 records.

Writes ride the normal save path, so AutoNumbers stamp and automations fire. The audit log attributes them to MCP · connection (as person).

Authentication

/mcp takes two credentials, and it is not the API's client-credentials flow either way. Whichever one arrives, the request resolves to the same acts-as person: the agent reads with that person's own permissions, so hidden fields stay hidden, no-access tables stay invisible, and admin-gated tools follow their role. Nothing downstream knows which credential was used.

A minted key

An MCP client reads one static line from a config file, so the first credential is a long-lived key (rfmcp_…) sent as Authorization: Bearer on every call. Mint one under Developers → AI agents (MCP). RowFold shows it once and stores a hash. An admin chooses which member it acts as. Revoking the key, or removing that member, ends it within a minute.

Signing in (OAuth 2.1)

Some clients cannot send a header at all — ChatGPT's custom connectors take OAuth or no authentication — so /mcp is also an OAuth 2.1 authorization server. Point such a client at the same URL with no credential: the 401 carries a WWW-Authenticate header naming /.well-known/oauth-protected-resource, and the client walks discovery, authorization and token exchange from there. Nobody pastes anything.

  • The token acts as whoever signed in and consented, and they pick the workspace on the consent screen — so nobody can grant access they do not already have.
  • Access tokens are signed JWTs that live 15 minutes and are validated offline; refresh tokens last 30 days and rotate on every use. Replaying a rotated refresh token revokes the whole family.
  • PKCE is required. A client identifies itself with a Client ID Metadata Document — a client_id that is an https URL RowFold fetches. Dynamic Client Registration is deliberately not offered.
  • Each person lists and disconnects their own agents under Settings → Security; admins see them on the Developers page under Agents connected by sign-in. Disconnecting bites within 15 minutes, as the current access token expires.

Unknown, revoked and dead credentials 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.