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
curl "$BASE/types/part" -H "X-API-Key: $KEY"
2 · Create a record
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):
{
"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
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:
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
v2alongsidev1;v1is 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:
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/queryis 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.
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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per window |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Seconds 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": "<code>",
"message": "<human, advisory>",
"issues": [ { "field": "...", "code": "...", "message": "..." } ]
} erroris a stable machine code, branch on this.messageis an advisory human string (always present).issuesappears on validation failures (error: "validation"), one entry per field problem; absent otherwise.
Common codes
| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing / invalid / expired key, or wrong project |
readOnlyKey | 403 | Write attempted with a read-only key |
ipNotAllowed | 403 | Caller IP not in the key's allowlist |
trialExpired | 402 | No active subscription (billing configured) |
tooManyRequests | 429 | Rate limit exceeded (see Retry-After) |
not_found | 404 | No such record / type / route |
in_use | 409 | Referenced elsewhere; cannot proceed |
precondition_failed | 412 | If-Match ETag no longer current |
unknown_type | 400 | Type key / id not in this project |
unsupported_media_type | 415 | Asset upload of a disallowed type |
too_large | 413 | Body or upload over the cap |
method_not_allowed | 405 | HTTP method not supported on the route |
validation | 400 | Per-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.
/typesReadList entity types with their fields.
/types/{key}ReadGet one entity type (by key or id) with its fields.
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.
{
"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
/entities/queryReadList records of one type (or module) with structured filters, sorts, and paging. Read-only keys may call it despite the POST method.
Request body
| Field | Type | Meaning |
|---|---|---|
type / module | string | Scope to one type key/id, or a module key/id (exactly one required) |
q | string | Substring match on name / reference_id |
filters | array | Conditions to apply (see below) |
match | "all" | "any" | Join filters with AND (default) or OR |
sorts | array | Sort order (see below) |
fields | string[] | Sparse selection, return only these keys in each record's attributes (top-level fields always included) |
trashed | boolean | When true, returns the type's Trash; filters and sorts are ignored |
updatedAfter | RFC3339 | Records modified strictly after this time (incremental sync) |
notInCollection | uuid | Exclude records already in this collection (picker use) |
limit | integer | Page size, default 50, max 200 (over-max clamps to 200) |
offset | integer | Page offset (offset-based paging) |
cursor | string | Opaque keyset cursor for stable large-dataset iteration |
Filters
{ "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) forin/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
{ "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.
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.
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
/entities/{id}ReadFetch one record by id.
/entities/by-ids?ids=a,b,cReadBatch fetch by id, any type (≤ 200).
/entities/by-reference/{type}/{referenceId}ReadFetch by your business key.
/entitiesWriteCreate. 201; body { type, name, referenceId?, attributes }.
/entities/{id}WriteFull replace. 200; omitted attributes are cleared.
/entities/{id}WritePartial update. Only the keys you send change; a JSON null clears a field, an omitted field is untouched.
/entities/{id}WriteSoft-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 type | JSON value |
|---|---|
| Text · Long text · Select · URL | string, e.g. "steel" |
| Integer · Decimal · Money · Percent | number, e.g. 0.12 |
| Boolean | true / false |
| Date | ISO 8601 string, e.g. "2026-06-22" |
| Multi-select | array of strings |
| Entity (reference) | the target record's id string |
| Collection | managed via the collection-member endpoints, not in attributes |
| Image · File | the 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.
curl -X PATCH "$BASE/entities/$ID" -H "X-API-Key: $KEY" \
-d '{"attributes":{"size_label":"20"}}' Idempotent upsert
/entities/by-reference/{type}/{referenceId}WriteCreate-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.
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:
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)
/entities/validateRead204 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:
{ "succeeded": <count>, "failed": [ { "id": "...", "code": "...", "message": "..." } ] } | Method | Path |
|---|---|
| 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
/entities/export?type={key}ReadAdd ?template=1 for a fillable template.
/entities/importWrite{ type, records:[...] }, upsert by referenceId; ≤ 2000 records.
/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):
/entities/{id}/collections/{fieldKey}/membersRead/entities/{id}/collections/{fieldKey}/membersWrite/entities/{id}/collections/{fieldKey}/members/{memberId}Write/entities/{id}/collections/{fieldKey}/members/{memberId}WriteReorder.
Structure tree (BOM)
/entities/{id}/structureReadThe 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
/entities/{id}/usagesReadThe parents that directly consume {id} as a component (single level), each with the per-line quantity and junctionId.
Relationships
/entities/{id}/relationshipsReadInbound 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
/entities/{id}/composition/linesWriteAdd a component: { "childId": "...", "quantity": "2", "fields": { "<key>": "<value>" } }. Requires a structure configured for the parent's type; self-reference and cycles are rejected (400).
/entities/{id}/composition/lines/{lineId}WriteEdit a line (supplied fields merge; an empty string clears a line field).
/entities/{id}/composition/lines/{lineId}WriteRemove a line.
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.
/assets/imagesWriteMultipart file; ≤ 20 MiB; jpeg / png / webp / gif.
/assets/filesWriteMultipart file; ≤ 10 MiB.
/assets/images/by-hash/{sha256}ReadDedup lookup.
/assets/files/by-hash/{sha256}Read# 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).