Data API · v1

API reference

A server-to-server HTTP API over a ManyRows project's records, authenticated with an API key. This is the data plane: records, collections, assets, compositions, and read-only schema discovery. Schema management lives in the dashboard.

Overview

The ManyRows Data API is a JSON HTTP API over one project's records. Use it to sync records into your stack, drive a product or storefront from your ManyRows data, or build automations on top of your model.

  • Discover types and fields, then read and write records of any type
  • Structured query with filters, sorts, sparse fields, and cursor paging
  • Idempotent upsert by your own business key, plus ETag compare-and-swap
  • Read BOM structure, where-used, and the relationship graph; author BOM lines
  • Collections, image and file uploads

Data plane only

Creating types and fields (schema management) is done in the dashboard, not through this API. Discovery endpoints here are read-only.

Quick start

Three calls to read and write your data. Set $BASE to your project's data URL (see Base URL) and $KEY to an API key from the dashboard.

1 · Discover a type and its fields

discoverbash
curl "$BASE/types/part" -H "X-API-Key: $KEY"

2 · Create a record

createbash
curl -X POST "$BASE/entities" -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "part",
    "name": "M3 bolt",
    "referenceId": "m3-bolt",
    "attributes": { "unitCost": 0.12, "material": "steel" }
  }'

The 201 response is the new record (with an ETag header):

responsejson
{
  "id": "0a9f3c2e-...-e8",
  "typeKey": "part",
  "referenceId": "m3-bolt",
  "name": "M3 bolt",
  "attributes": { "unitCost": 0.12, "material": "steel", "supplier": "7c1d...e8" },
  "createdAt": "2026-06-22T10:04:11Z",
  "updatedAt": "2026-06-22T10:04:11Z"
}

3 · Query records

querybash
curl -X POST "$BASE/entities/query" -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "part", "limit": 50 }'

That's the core loop. From here, add filters and paging on query, update with PATCH / PUT, or make writes idempotent with upsert by reference.

Base URL

Every endpoint is scoped to a workspace and a project:

base urlhttp
https://<host>/x/{workspaceId}/api/v1/projects/{projectId}/data
  • host, workspaceId, and projectId are shown in the dashboard (the IDs are UUIDs).
  • v1 is the API version. A future breaking change ships as v2 alongside v1; v1 is never mutated in place.

All paths in this reference are relative to that base. For example POST /entities/query means a POST to .../data/entities/query. Examples below use $BASE for the base URL and $KEY for your API key.

Authentication

Create an API key in the dashboard and send it in either header:

headershttp
X-API-Key: mr_<prefix>_<secret>
Authorization: Bearer mr_<prefix>_<secret>

Key properties

  • Read-only, any write (PUT / PATCH / DELETE) returns 403 readOnlyKey. POST /entities/query is query-only and is allowed for read-only keys.
  • Project-scoped, a request for another project returns 401.
  • Expiry, after it expires the key returns 401.
  • IP allowlist, a request from a disallowed IP returns 403 ipNotAllowed (entries may be plain IPs or CIDR blocks).
  • Rotatable, a new secret is issued and the old one stops working immediately.
first requestbash
curl -s -X POST "$BASE/entities/query" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"type":"cymbal","limit":10}'

Limits

Rate limit

Per-key, per-minute. Every response carries the budget so you can back off proactively:

HeaderMeaning
X-RateLimit-LimitRequests allowed per window
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetSeconds until the budget refills

Exceeding it returns 429 with a Retry-After header. Monthly/lifetime call quotas are not enforced today.

Entitlement & body size

  • If billing is configured and the workspace has no active subscription (or its trial expired), requests return 402 trialExpired. When billing is unconfigured this gate is inert.
  • Request bodies are capped at 1 MB (asset uploads excepted); oversized returns 413.

Responses & correlation

  • Success bodies are JSON. Single-record reads and writes also send an ETag (the record's version), see Concurrency.
  • Every response carries an X-Request-Id. Quote it when reporting a problem; it matches the server access log.

Errors

Every error shares one envelope:

error envelopejson
{
  "error": "<code>",
  "message": "<human, advisory>",
  "issues": [ { "field": "...", "code": "...", "message": "..." } ]
}
  • error is a stable machine code, branch on this.
  • message is an advisory human string (always present).
  • issues appears on validation failures (error: "validation"), one entry per field problem; absent otherwise.

Common codes

CodeHTTPMeaning
unauthorized401Missing / invalid / expired key, or wrong project
readOnlyKey403Write attempted with a read-only key
ipNotAllowed403Caller IP not in the key's allowlist
trialExpired402No active subscription (billing configured)
tooManyRequests429Rate limit exceeded (see Retry-After)
not_found404No such record / type / route
in_use409Referenced elsewhere; cannot proceed
precondition_failed412If-Match ETag no longer current
unknown_type400Type key / id not in this project
unsupported_media_type415Asset upload of a disallowed type
too_large413Body or upload over the cap
method_not_allowed405HTTP method not supported on the route
validation400Per-field problems in issues (codes: required, invalid, unknown_field, duplicate)

Schema discovery

Learn the field keys you need for filtering, sorting, and attribute payloads. Both endpoints are read-only.

GET/typesRead

List entity types with their fields.

GET/types/{key}Read

Get one entity type (by key or id) with its fields.

shapetext
GET /types        -> { "types": [ { key, name, nameUnique, builtIn, fields:[...] } ] }
GET /types/{key}  -> { key, name, nameUnique, builtIn, fields:[...] }

Each field descriptor is { key, type, required, options?, targetType?, targetModule?, elementType? }. options lists select / multi-select values; targetType is the referenced type's key (entity / collection fields); elementType is a collection's element kind.

GET /types/partjson
{
  "key": "part",
  "name": "Part",
  "nameUnique": false,
  "builtIn": false,
  "fields": [
    { "key": "unitCost", "type": "money",  "required": false },
    { "key": "material", "type": "select", "options": ["steel", "aluminium", "brass"] },
    { "key": "supplier", "type": "entity", "targetType": "supplier" }
  ]
}

Query / list records

POST/entities/queryRead

List records of one type (or module) with structured filters, sorts, and paging. Read-only keys may call it despite the POST method.

Request body

FieldTypeMeaning
type / modulestringScope to one type key/id, or a module key/id (exactly one required)
qstringSubstring match on name / reference_id
filtersarrayConditions to apply (see below)
match"all" | "any"Join filters with AND (default) or OR
sortsarraySort order (see below)
fieldsstring[]Sparse selection, return only these keys in each record's attributes (top-level fields always included)
trashedbooleanWhen true, returns the type's Trash; filters and sorts are ignored
updatedAfterRFC3339Records modified strictly after this time (incremental sync)
notInCollectionuuidExclude records already in this collection (picker use)
limitintegerPage size, default 50, max 200 (over-max clamps to 200)
offsetintegerPage offset (offset-based paging)
cursorstringOpaque keyset cursor for stable large-dataset iteration

Filters

filter entriesjson
{ "field": "status", "op": "eq",  "value": "active" }
{ "field": "score",  "op": "gte", "value": 90 }
{ "field": "tag",    "op": "in",  "values": ["a", "b"] }
{ "field": "note",   "op": "blank" }
  • Use value (string, number, or boolean; coerced per field type) for scalar ops.
  • Use values (array of scalars) for in / not-in.
  • Omit both for blank / present.

Operators: eq, neq, contains (text only, case-insensitive), gte, lte, blank, present, in, not-in. neq / not-in treat an unset field as "not equal". Builtins: name (eq, neq, contains), reference_id (eq, neq, contains, in, not-in), id (eq, neq, in, not-in). Not every operator applies to every field type, discovery plus a 400 tell you which.

Sorts

Each entry is { "field": "created_at", "dir": "desc" }. dir is asc (default) or desc. Builtins: name, reference_id, created_at, updated_at; or any field key.

Response

responsejson
{ "entities": [ ... ], "total": 123, "trashedTotal": 4,
  "limit": 50, "offset": 0, "nextCursor": "eyJjIjoibmFtZS..." }

Cursor pagination

Recommended for iterating large sets: when the response includes nextCursor, pass it back as cursor in the next request body. Keyset paging doesn't skip or duplicate rows under concurrent writes the way deep offset does. The cursor is valid for a single builtin sort (name / reference_id / created_at / updated_at, including the default name); use a single-entry sorts with that column. An absent nextCursor means you've reached the end. cursor overrides offset when both are supplied.

filter + sort + limitbash
curl -X POST "$BASE/entities/query" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "contact",
    "filters": [{ "field": "status", "op": "eq", "value": "active" }],
    "sorts": [{ "field": "created_at", "dir": "desc" }],
    "limit": 50
  }'

Incremental sync

Poll with updatedAfter and an ascending updated_at sort, checkpointing the largest updatedAt you've seen. Treat re-delivered rows as idempotent upserts.

incremental syncbash
curl -X POST "$BASE/entities/query" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"type":"cymbal","sorts":[{"field":"updated_at","dir":"asc"}],
       "updatedAfter":"2026-06-22T00:00:00Z"}'

Get · create · replace · patch · delete

GET/entities/{id}Read

Fetch one record by id.

GET/entities/by-ids?ids=a,b,cRead

Batch fetch by id, any type (≤ 200).

GET/entities/by-reference/{type}/{referenceId}Read

Fetch by your business key.

POST/entitiesWrite

Create. 201; body { type, name, referenceId?, attributes }.

PUT/entities/{id}Write

Full replace. 200; omitted attributes are cleared.

PATCH/entities/{id}Write

Partial update. Only the keys you send change; a JSON null clears a field, an omitted field is untouched.

DELETE/entities/{id}Write

Soft-delete to Trash. 204; idempotent.

attributes is keyed by field key; values are bare JSON. referenceId is your portable business key; it auto-generates from the name if omitted on create.

How values are encoded

Field typeJSON value
Text · Long text · Select · URLstring, e.g. "steel"
Integer · Decimal · Money · Percentnumber, e.g. 0.12
Booleantrue / false
DateISO 8601 string, e.g. "2026-06-22"
Multi-selectarray of strings
Entity (reference)the target record's id string
Collectionmanaged via the collection-member endpoints, not in attributes
Image · Filethe descriptor returned by an asset upload

For the exact, authoritative per-type rules, export with ?template=1, the response's $spec documents the encoding with a worked example.

patch one fieldbash
curl -X PATCH "$BASE/entities/$ID" -H "X-API-Key: $KEY" \
  -d '{"attributes":{"size_label":"20"}}'

Idempotent upsert

PUT/entities/by-reference/{type}/{referenceId}Write

Create-or-update keyed on your own id, retries are safe (no duplicate). 201 created | 200 updated.

Body is { name, attributes }; referenceId comes from the URL. If a record with that reference exists it is replaced; otherwise it is created. This is the recommended way to make writes idempotent.

upsert by your keybash
curl -X PUT "$BASE/entities/by-reference/part/m3-bolt" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "M3 bolt", "attributes": { "unitCost": 0.14 } }'
# -> 201 created  |  200 updated  (run it again, same result, no duplicate)

Concurrency (ETag / If-Match)

Reads and writes return an ETag (the record's version). To avoid clobbering a concurrent update, echo it back on a write with If-Match:

compare-and-swapbash
ETAG=$(curl -sD- "$BASE/entities/$ID" -H "X-API-Key: $KEY" -o /dev/null \
  | tr -d '\r' | awk -F': ' '/^ETag/{print $2}')

curl -X PATCH "$BASE/entities/$ID" -H "X-API-Key: $KEY" \
  -H "If-Match: $ETAG" -d '{"name":"New"}'
# -> 412 precondition_failed if the record changed since you read it

If-Match is optional; without it a write is unconditional. With it, the write is an atomic compare-and-swap on the record's version, so concurrent writers can't lose an update.

Validate (dry run)

POST/entities/validateRead

204 if valid, else the same validation body a create returns.

Bulk & trash

All bulk ops are capped at 200 ids/call and share one result envelope:

bulk resultjson
{ "succeeded": <count>, "failed": [ { "id": "...", "code": "...", "message": "..." } ] }
MethodPath
POST/entities/bulk-delete
POST/entities/bulk-restore
POST/entities/bulk-purge
POST/entities/bulk-set-field
POST/entities/bulk-duplicate
POST/entities/empty-trash
GET/entities/counts
GET/entities/{id}/reference-count
GET/entities/{id}/reverse/{fieldKey}

Import / export

GET/entities/export?type={key}Read

Add ?template=1 for a fillable template.

POST/entities/importWrite

{ type, records:[...] }, upsert by referenceId; ≤ 2000 records.

POST/entities/import-multiWrite

{ types:[{ type, records:[...] }, ...] }, referenced types are imported first.

The export response is { type, version, $spec, fields, total, truncated, records:[...] }. $spec and fields are always present and teach how to author records, $spec carries the per-type value-encoding rules and a worked example; fields lists each attribute with its type, select options, and reference targets. truncated is true when the 10k export cap cut it short (page the rest with the query endpoint).

The import response is { succeeded, created, updated, failed, ignoredKeys }. ignoredKeys lists attribute keys that aren't fields on the type; they are skipped (not an error), so a typo'd key surfaces here instead of vanishing silently.

Bring your own AI

Export with ?template=1 to get the $spec + fields, hand that to any AI to generate records, then import them back.

Collection members

Members of a collection field on a record (paged):

GET/entities/{id}/collections/{fieldKey}/membersRead
POST/entities/{id}/collections/{fieldKey}/membersWrite
DELETE/entities/{id}/collections/{fieldKey}/members/{memberId}Write
PATCH/entities/{id}/collections/{fieldKey}/members/{memberId}Write

Reorder.

Structure tree (BOM)

GET/entities/{id}/structureRead

The descended composition tree rooted at {id}.

Returns { "configured": false } when the record's type has no structure configured. Otherwise: root, nodes[] (each with level, position, cycleCut, the per-line line attributes, and a child summary with hasChildren), plus quantityKey and lineColumns describing the junction's scalar columns.

Where-used

GET/entities/{id}/usagesRead

The parents that directly consume {id} as a component (single level), each with the per-line quantity and junctionId.

Relationships

GET/entities/{id}/relationshipsRead

Inbound field-keyed references to {id}, grouped by source field.

Each group carries fieldKey, fieldLabel, total, and a shown sample of referrers. BOM junction fields are excluded (use structure / usages for those). Outbound references are just the record's own ref / collection field values.

Authoring BOM lines

POST/entities/{id}/composition/linesWrite

Add a component: { "childId": "...", "quantity": "2", "fields": { "<key>": "<value>" } }. Requires a structure configured for the parent's type; self-reference and cycles are rejected (400).

PUT/entities/{id}/composition/lines/{lineId}Write

Edit a line (supplied fields merge; an empty string clears a line field).

DELETE/entities/{id}/composition/lines/{lineId}Write

Remove a line.

add a component linebash
curl -X POST "$BASE/entities/$PARENT/composition/lines" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{ "childId": "<part-id>", "quantity": "4",
        "fields": { "reference_designator": "J1" } }'

Defining a structure (which type is a BOM, its junction / quantity fields) is a dashboard operation. The API authors lines against an already-configured structure.

Assets

Upload bytes, then bind the returned descriptor through a normal entity create / update on an image or file field.

POST/assets/imagesWrite

Multipart file; ≤ 20 MiB; jpeg / png / webp / gif.

POST/assets/filesWrite

Multipart file; ≤ 10 MiB.

GET/assets/images/by-hash/{sha256}Read

Dedup lookup.

GET/assets/files/by-hash/{sha256}Read
upload, then bindbash
# 1. upload the bytes (multipart) -> returns a descriptor for the stored image
curl -X POST "$BASE/assets/images" -H "X-API-Key: $KEY" \
  -F "[email protected]"

# 2. bind the descriptor on an image field via a normal create/update
curl -X PATCH "$BASE/entities/$ID" -H "X-API-Key: $KEY" \
  -d '{ "attributes": { "photo": <descriptor from step 1> } }'

Oversized / unsupported uploads return 413 too_large / 415 unsupported_media_type.

SDKs

  • Go: github.com/manyrows/manyrows-goclient
  • Other languages: use any HTTP client with the headers above.

Not yet available

Polling only, there are no change webhooks yet. There is no per-request Idempotency-Key (use upsert-by-referenceId for safe retries).