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, plus project-wide search
- Idempotent upsert by your own business key, plus ETag compare-and-swap
- Read BOM structure, where-used, and the relationship graph; author BOM lines; substitute components
- Explode a BOM into a parts summary, evaluate declared rollups, track life limits, trace recall impact
- Propose changes to governed records through change requests (a human approves in the dashboard)
- 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, every mutating method returns
403 error.readOnlyKey— PUT, PATCH, DELETE and POST. The two exceptions are the reads that are expressed as a POST:/entities/queryand/entities/validate. Every other POST (including/entitiesand the bulk ops) is refused. - 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-workspace, per-minute — the budget is a workspace entitlement sized by plan, so all of a workspace’s API keys share one bucket (minting extra keys does not multiply the allowance). On a plan with a finite limit, 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. Unlimited (Enterprise) plans are not throttled and omit the X-RateLimit-* headers. 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.
Two shapes of code
Gateway errors — raised by the auth, entitlement and rate-limit layer before your request reaches a handler — carry an error.-prefixed code. Endpoint errors are flat. Match the exact string including the prefix where shown: re-auth on error.unauthorized, not unauthorized.
Common codes
| Code | HTTP | Meaning |
|---|---|---|
error.unauthorized | 401 | Missing / invalid / expired key, or wrong project |
error.readOnlyKey | 403 | Write attempted with a read-only key |
error.ipNotAllowed | 403 | Caller IP not in the key’s allowlist |
error.trialExpired | 402 | No active subscription (billing configured) |
error.tooManyRequests | 429 | Rate limit exceeded (see Retry-After) |
error.internalError | 500 | Unexpected server-side failure — quote the X-Request-Id |
not_found | 404 | No such record / type / route |
invalid_json | 400 | Body was not parseable JSON |
in_use | 409 | Referenced elsewhere; cannot proceed |
cr_required | 409 | The type requires changes to go through a change request, see Propose changes |
cr_locked | 409 | Record locked by an open change request (the body names it) |
cr_not_author | 403 | Change request was not opened by this key |
governed_trash_requires_reason | 409 | Trashing a governed, BOM-used record needs a reason |
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) |
Text values are trimmed before they are validated or stored, so a required field is not satisfied by "" or " " — both come back as required. Send the value or omit the key; sending whitespace is the same as sending nothing. Trimming applies to text, long text and rich text, at the edges only.
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" }
]
} Catalogs & categories
Catalogs classify records into a category tree. Discover them, then filter /entities/query by a category (the categories field).
/catalogsReadThe project’s catalogs. Each targets one entity type or base type and owns a tree of categories.
/catalogs/{id}/categoriesReadA catalog’s categories, flat. Nest by parentId (absent = a root). Each carries a rollup count — entities filed into it or any descendant.
/entities/{id}/categoriesReadHow one record is classified: for each catalog that applies to its type, the catalog’s category tree plus the record’s currentCategoryId (absent = unassigned). Setting/clearing an assignment is admin-only.
GET /catalogs -> { "catalogs": [ { id, key, name, kind, targetEntityTypeId?, targetBaseTypeId?, categoryCount } ] }
GET /catalogs/{id}/categories -> { "categories": [ { id, parentId?, name, code?, description, startDate?, endDate?, effective, position, count } ] }
GET /entities/{id}/categories -> { "assignments": [ { catalogId, catalogKey, catalogName, currentCategoryId?, categories:[…] } ] } kind is entity_type or base_type (what the catalog classifies). Use a category id to filter records, see Query / list below. effective reflects the optional startDate/endDate window (today within the range, inclusive): records can only be newly filed into an effective category, existing assignments are kept.
Query / list records
/entities/queryReadList records of one entity type (or one base type) with structured filters, sorts, and paging. Read-only keys may call it despite the POST method.
Request body
| Field | Type | Meaning |
|---|---|---|
type / baseType | string | Scope to one entity-type key/id, or a base-type 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) |
scope | string | Which lifecycle plane to list: active (default), archived, draft, trashed. On any plane but active, filters and sorts are dropped — those are flat, recency-ordered lists |
trashed | boolean | Legacy shorthand for scope: "trashed". Don’t send both: trashed: true forces the Trash plane unless scope is "archived" |
updatedAfter | RFC3339 | Records modified strictly after this time (incremental sync) |
effectiveOn | date | As-of lens, YYYY-MM-DD or RFC3339: keep only records whose effectivity window covers that day (day-granular, both bounds inclusive) |
notInCollection | uuid | Exclude records already in this collection (picker use) |
unreferencedOnly | boolean | Restrict to records no live record references — the "unused" view |
categories | array | Classification filters, records filed into a catalog category (see below) |
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 |
countTotal | boolean | Default true. Set false when paging purely to collect ids — see below |
Skipping the counts. Every page carries a total, and an active-scope page also carries trashedTotal / archivedTotal / draftTotal. Each is a separate scan of the same predicate, so walking a large result set pays four of them per page for numbers a bulk enumeration never reads. Send "countTotal": false to skip all four; they come back as -1, which is visibly “not counted” — 0 would be indistinguishable from an empty result. The rows are unchanged, and omitting the field means true, so existing callers keep the totals they expect.
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.
Category filters
Restrict to records filed into a catalog category. Get the ids from GET /catalogs and GET /catalogs/{id}/categories (see Catalogs & categories). Each entry ANDs:
{ "catalogId": "...", "categoryId": "...", "includeSubtree": true } includeSubtree (default false) also matches records filed into any descendant category — e.g. filtering by Components with includeSubtree: true returns everything under it. Omit categoryId (or send "") to match records filed into any category of the catalog — a catalog-only filter, e.g. { "catalogId": "..." }.
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"}' Search (all types)
/search?q={fragment}ReadFind records by name or reference id across every type in the project — the lookup a type-scoped query can’t express, since per-type numbering means a reference id isn’t unique project-wide.
Matches name and reference id only (not attribute values), ranked exact reference id, then reference-id prefix, then name prefix, then contains-anywhere. Archived and draft records are returned, each labelled via state; trashed records and change-request working copies are not. limit defaults to 50 (max 200); total is the true pre-limit match count, so a capped list can report the truncation. A blank q returns an empty result set, not an error.
GET /search?q=FW26-001 -> { "query": "FW26-001", "total": 3,
"results": [ { entityId, name, referenceId, typeKey, typeName, state } ] } 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. Optional body { "reason": "..." } — required when the record’s type is change-controlled and the record is used in a BOM (else 409 governed_trash_requires_reason, listing the affected BOM parents).
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) If the entity type requires a change request on create (requiresCrOnCreate), an upsert that creates a record returns it as a draft (draftedAt set) instead of publishing it live — see Duplicate & drafts. It must be activated before it appears in queries; subsequent upserts to the same referenceId update that draft in place.
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/bulk-archive |
| POST | /entities/bulk-unarchive |
| POST | /entities/empty-trash |
| POST | /entities/bulk-apply-size-spec-template |
| POST | /entities/bulk-apply-ta-template |
| GET | /entities/counts |
| GET | /entities/{id}/reference-count |
| GET | /entities/{id}/reverse/{fieldKey} |
bulk-set-field takes { "type": ..., "ids": [...], "fields": [ {"field": "season", "value": "FW26"}, {"field": "shipDate", "clear": true} ] } — one or more fields, each set (or cleared) to one shared value across the selection; the whole request is rejected if any entry is invalid.
bulk-delete moves records to the per-type Trash (recover with bulk-restore, free the quota with bulk-purge). bulk-archive hides records from grids and pickers while keeping them live in existing references (reverse with bulk-unarchive). Both take { "ids": [...] }. bulk-delete also accepts an optional reason — required when any selected record is change-controlled and used in a BOM; without one the whole call is refused with 409 governed_trash_requires_reason, naming the affected BOM parents.
The two bulk-apply-…-template ops take { "entityIds": [...], "templateId": "..." } and apply one measurement (size-spec) or Time & Action template across the selection, skipping names already present (idempotent); the response tallies { entities, added, skipped }. Size specs are product-definition data: the whole request is refused (409) if any selected live record is change-controlled or locked by an open change request. T&A is operational data and applies to governed records too; only change-request working copies are refused.
Import / export
/entities/export?type={key}ReadAdd ?template=1 for a fillable template, or ?allPlanes=1 to include draft & archived records (with effectivity) for a full round-trip. Returns up to 10k records per call — resume with ?offset=.
The export can be narrowed to a subset rather than the whole type. Everything below is ignored when ?ids= names an explicit selection, which stands on its own:
| Query param | Meaning |
|---|---|
ids | Comma-separated record ids — export exactly these |
fields | Comma-separated field keys to limit the exported attributes (default all importable fields) |
q | Substring match on name / reference id |
filter | Repeatable, colon-delimited key:op[:value] — the same operators as a query filters entry |
match | all (default) or any |
category | Repeatable, colon-delimited: catalogId, catalogId:categoryId, or catalogId:categoryId:1 for that category and its subtree |
unreferencedOnly | 1 / true for records nothing live references |
effectiveOn | As-of lens, YYYY-MM-DD or RFC3339 |
A malformed category, filter, effectiveOn or offset is a 400, never a silently unscoped export — a dropped scope would return every record and look exactly like a complete one.
/entities/importWrite{ type, records:[...], draft? }, upsert by referenceId; ≤ 2000 records. Set draft: true to stage rows for review (imports into change-controlled types).
/entities/import-multiWrite{ types:[{ type, records:[...] }, ...] }, referenced types are imported first.
The export response is { type, version, $spec, fields, total, truncated, nextOffset?, 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 — the response then carries nextOffset; pass it back as ?offset= to fetch the next window, repeating until truncated is false.
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.
Importing a bill of materials
/entities/import-bomWriteImport an indented BOM — the parts and the structure between them, in one call. This is the CAD-facing seam: we do not read CAD files, but every CAD tool exports this table.
Structure is carried by a level column rather than stated: each row belongs to the nearest row above it whose level is one lower. Parts are upserted by referenceId (the part number), so re-importing after a design change updates the tree instead of laying a second one beside it, and a part appearing under several parents stays one record with several lines.
{
"type": "part",
"dryRun": false,
"rows": [
{ "level": 0, "referenceId": "ASM-100", "name": "Gearbox" },
{ "level": 1, "referenceId": "SHF-12", "name": "Shaft", "quantity": "1" },
{ "level": 1, "referenceId": "BRG-04", "name": "Bearing", "quantity": "2",
"fields": { "material": "steel" } }
]
} You can also POST a CAD tool’s CSV export verbatim as text/csv, passing the type as ?type={key} (and ?dryRun=1 if you want a rehearsal). Columns are matched by meaning, so dialects differ harmlessly — SolidWorks writes “PART NUMBER” / “QTY.”, Onshape “Part number” / “Quantity”. Nesting is read from whichever convention the export uses: a level column, a dotted item number (1.2.1), or leading whitespace in the part number.
The response is { partsCreated, partsMatched, linesCreated, linesUpdated, rootReferenceId, dryRun }. partsMatched counts part numbers recognised and linked — nothing about the part itself is overwritten, so a re-import changes structure and quantities only.
Malformed files are refused whole rather than half-imported: a level that jumps by more than one means a row was lost, a row shallower than the first is a second assembly, and a file with no nesting convention at all is rejected rather than imported flat. 422 if the type has no composition configured; 409 if the type is change-request governed — a BOM is product definition, so its structure goes through review rather than an import.
Duplicate & drafts
Duplicate a record
/entities/{id}/duplicateWriteDeep-copy one record. The body selects what to carry over (an omitted/empty list copies none of that category).
{
"name": "Copy of Widget",
"copyAttributeFieldIds": ["<fieldId>", "..."],
"copyCollectionFieldIds": ["<fieldId>", "..."],
"copyComponents": true,
"copyChildren": [ { "typeId": "<child entity-type id>", "fieldId": "<forward entity-field id on that type>" } ]
} copyComponents copies the BOM / structure subtree. Field ids come from Types & fields. Returns the new record.
copyChildren deep-copies one-to-many children onto the clone: for each {typeId, fieldId} group, every entity of typeId whose fieldId points at the source is cloned full-fat and re-pointed at the new root (one level only, ≤200 children per group). Discover the copyable groups with GET /entities/{id}/child-ref-groups → { "groups": [ { typeId, typeName, fieldId, fieldLabel, count } ] }. When sent, the response gains a children envelope alongside the new entity: { "succeeded": N, "failed": [...] }.
Draft activation
Records can be created as drafts: not live yet, hidden from live reads, and editable without a change request. Drafting defers the change request — a change-controlled type takes one at activation rather than on every edit — not the rules: a draft is validated like any other record, required fields included, so an incomplete write is rejected whether or not draft is set. Promoting a draft activates its whole reference closure at once.
/entities/{id}/activation-closureReadPreview: { entities:[...] } that would activate together.
/entities/{id}/activateWriteNo body. Promote the closure to Active.
activate returns { "activated": [ids...] } (200) on a direct publish. If any member of the closure belongs to a type that requires a change request, it stages one instead and returns { "changeRequest": {...} } (201, pending) — approving that request is a dashboard action, so a key-staged activation waits for a human approver.
Propose changes (change requests)
Writing directly to a record whose type requires a change request returns 409 cr_required. Propose the change instead — open a change request, edit the working copy it hands you, submit, poll:
POST /change-requests {"title":"CAD sync: wing geometry","rootEntityId":"<id>"}
-> 201 {"changeRequest":{"id":"<crId>",...},"rootWorkingCopyId":"<wcId>"}
PATCH /entities/<wcId> edit the WORKING COPY, not the live record
POST /change-requests/<crId>/submit
GET /change-requests/<crId> poll changeRequest.status Opening clones the record (and its BOM subtree) into staging and returns rootWorkingCopyId — every edit goes to that id with the normal record endpoints, and the live record is untouched until the change request is approved. Submitting moves the draft into review; when you name no approvers, the type’s default roster applies, exactly as from the dashboard. Change requests a key opens are attributed to apikey:<keyName>.
/change-requestsReadList the project’s change requests, paged (limit / offset; total is the full match count). Readable by any key — change requests are project data.
/change-requestsWriteOpen a change request on a governed record: { "title": "...", "rootEntityId": "..." } → 201 with the change request plus rootWorkingCopyId. Created as a draft; it enters review only on submit. Idempotent for a root already staged under your draft; 409 if another open change request covers the record.
/change-requests/bulk-fieldWriteThe batch counterpart — one field set across many records (a nightly ECO run, a season rollover). Same propose-only semantics: created for review, not applied.
/change-requests/{id}ReadRead one change request; its status tells you whether your proposal is still in review.
/change-requests/{id}/submitWriteMove the draft into review. 403 cr_not_author unless this key opened it; 409 unless it is still a draft.
Optional body { "requiredApprovers": ["[email protected]"], "classification": "minor" | "major" }. Both may be omitted: no approvers means the type’s default roster, and an absent classification means major, the conservative default. An unrecognised classification is rejected rather than quietly downgrading review.
/change-requests/{id}WriteAbandon a draft change request this key opened (403 cr_not_author otherwise). 204.
Approving is a human act
The approve and reject endpoints simply do not exist on this API — a person decides in the dashboard. Poll changeRequest.status: open while in review, and the change request disappears once applied (its history lives on as the record’s revisions).
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/bulk-deleteWriteRemove several members at once: { "ids": [...] } (or { "all": true }).
/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.
Parts summary (BOM explosion)
/entities/{id}/parts-summaryReadOrder-free BOM explosion: every leaf material with its compounded quantity per one unit of the root.
qtyPerUnit compounds consumption alone; qtyGross additionally compounds each line’s wastage percent — the procurement figure (the two are equal when no wastage field is configured, see hasWastage). Add ?asOf=YYYY-MM-DD to restrict the walk to BOM lines effective on that date, and ?asOfUnit= to restrict it to a build / PO / batch ordinal (the two AND). Pass the same lens you pass to structure — exploding on one axis and not the other returns a parts list for a different set of lines than the tree.
state is no_bom when the record’s type has no structure configured, and multi_output_formula when the composition carries Co-Product or By-Product lines: the walk treats every line as consumed, so exploding one would report an output as a material to buy. No rows come back in that case rather than wrong ones.
{ "state": "ready", "hasWastage": true,
"rows": [ { materialId, materialName, materialRef,
qtyPerUnit, qtyGross, unit, supplierId, supplierName } ] } Rollups
/entities/{id}/rollupsReadEvaluate every numeric rollup the record’s type declares over its BOM (mass, packaging weight, lead time…). Configuring rollups is a dashboard operation.
A sum rollup is Σ(child value × line quantity) over the whole BOM, bottoming out at components with no lines; a node with lines contributes its children’s sum, and its own value for the source field comes back as ownValue — a variance signal, never an input. Line quantity is the raw quantity (wastage inflates cost, not a physical rollup). A critical-path rollup is own + max(child) — quantity deliberately ignored — and carries path[], the gating chain from root to the leaf that drives the total.
leafCount / uncapturedCount report how many components were consulted and how many carry no value, so a total with gaps is never passed off as complete. ?asOf works as on parts-summary. configured is false when the type declares no rollups or has no BOM structure; an unsupportedReason (sheet-level multi_output_formula, per-rollup mixed_line_units) marks a result that was declined rather than computed wrong.
{ "configured": true,
"rollups": [ { name, unit, total, ownValue?, leafCount, uncapturedCount,
uncaptured:[ { entityId, name, referenceId } ],
lines:[ { childEntityId, childName, childRef, quantity, unitValue, extended } ],
path?:[ { entityId, name, referenceId, ownValue, cumulative } ] } ] } Where-used
/entities/{id}/usagesReadWhere-used, both levels of the answer: the parents that directly consume {id}, and the whole upward walk to the top-level items that reach it.
{ "usages": [ { parent, quantity, junctionId } ],
"ancestors": [ { parent, consumed, quantity, junctionId, depth, cycleCut } ],
"roots": [ { ...parent, sellable, depth, via, viaCount } ],
"itemCount": 9, "rootCount": 2, "maxDepth": 3 } usages is the direct parents (each with the rendered per-line quantity and its junctionId), ancestors every upward edge with its depth (1 = direct) and a cycleCut flag where the walk stopped on a repeat, and roots the ancestors nothing further consumes — a finished item, flagged sellable when its type is listable.
Every record in a where-used answer carries state — active, archived or draft. Archived and draft parents are included on purpose: an archived assembly still consumes this record, which is exactly what a caller retiring or substituting it must see. Do not read an unfiltered count as live demand.
/entities/{id}/alternate-usagesReadThe reverse for approved alternates: the BOM lines that list {id} as an approved alternate, each naming the parent assembly and the primary child it stands in for (deduped by junction).
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. Each referrer carries a state (active / archived / draft) — as with where-used, archived and draft referrers are listed deliberately, so distinguish them rather than treating every row as live. 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). Line writes return an ETag — send it back as If-Match to fail with 412 if the line changed since you read it.
/entities/{id}/composition/lines/{lineId}WriteRemove a line. Honours If-Match the same way.
/entities/{id}/composition/lines/{lineId}/promote-alternateWriteSwap the line’s primary child with one of its approved alternates: { "alternateId": "..." }. The displaced primary drops back into the alternates list. A direct edit, not a change request: records a quiet revision on a revision-controlled parent; 409 cr_locked if an open change request locks the parent, 409 already_in_bom if the alternate already has its own line under the same parent.
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" } }' One component, many parents
/entities/bulk-add-composition-lineWriteAdd one component to the primary BOM of many records at once: { "parentIds": [...], "childId": "...", "quantity": "2"? }, at most 200 parents.
Per parent, not wholesale: a parent that cannot take the line is skipped and reported with a skipReason rather than failing the call, so one bad target does not sink the rest. The reasons are missing (no such record), no_bom (its type has no structure configured), duplicate (it already lists the component), cycle (the add would make the BOM circular), cr_locked (an open change request locks it), frozen, archived, draft, and staged (it is a change request’s working copy, not a record). childId must be an accepted component type of every non-skipped parent’s structure, or the call is rejected (400 child_type). quantity is a decimal string and must be greater than zero; omitted leaves the line without an explicit quantity, which reads as 1 downstream. A direct edit, not a change request: it records a quiet revision on revision-controlled parents. Pass ?preview=1 to run the whole operation and roll it back.
{ "added": 7, "revisionControlledParents": 3,
"parents": [ { id, name, referenceId, revisionControlled, added, skipReason? } ] } 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.
Substitution
/entities/{id}/substituteWriteReplace this component with another across the BOMs that consume it.
Body { "replacementId": "...", "parentIds": [...]? } — replacementId must be the same entity type; parentIds scopes the swap to a chosen set of parents (omitted or empty = every affected parent; ids outside the affected set are ignored). Every consuming BOM line is repointed, merging quantities where a parent already lists the replacement. A direct edit, not a change request: it records a quiet revision on revision-controlled parents and skips parents locked by an open change request (reported in crLockedSkipped). Pass ?preview=1 to run the whole operation and roll it back — the returned counts equal a real run’s without mutating anything.
{ "affectedLines": 12, "affectedParents": 5, "mergedLines": 2,
"revisionControlledParents": 3, "crLockedSkipped": 1,
"parents": [ { id, name, referenceId, revisionControlled, lines, mergedLines } ] } Component-wide edits
/entities/{id}/composition/bulk-editWriteRemove this component from every BOM that uses it, or set its quantity there.
{id} is the component, and the blast radius is the one substitute acts on: every BOM line whose child is {id}. These are the two verbs substitution cannot express. Body { "op": "remove" | "set_quantity", "quantity": "2"?, "parentIds": [...]? } — quantity is a decimal string, required for set_quantity, ignored for remove, and must be greater than zero (400 non_positive); parentIds scopes the edit the same way substitution does (omitted or empty = every affected parent; ids outside the affected set are ignored). Pass ?preview=1 to run the whole operation and roll it back.
A direct edit, not a change request: it records a quiet revision on revision-controlled parents, and skips — per parent, leaving the rest of the run to proceed — parents locked by an open change request (crLockedSkipped), archived and draft parents (nonLiveSkipped), and a formula’s output lines, where the component is produced rather than consumed (outputLinesSkipped). A frozen parent is the one exception: it refuses the whole call with 409 entity_locked and nothing is applied, so a run either clears every freeze or changes nothing.
{ "op": "set_quantity", "affectedLines": 9, "affectedParents": 4,
"revisionControlledParents": 2, "crLockedSkipped": 1,
"nonLiveSkipped": 2, "outputLinesSkipped": 0,
"parents": [ { id, name, referenceId, revisionControlled, lines } ] } Life limits
/entities/{id}/lifeReadLife remaining on a life-limited unit — accumulated usage against the limit its type declares.
For a part good for N km / cycles / wears: sums the usage amount of every live record referencing the unit and reports it against the type’s declared limit. The total is derived from the events that produced it, never materialised, so it cannot drift; trashed, archived, draft and change-request records never contribute. status is ok, warning (at or past 80% consumed) or exceeded; remaining goes negative past the limit — information, not an error. configured is false when the type declares no life limit. Distinct from where-used.
{ "configured": true, "limit": 500, "accumulated": 412,
"remaining": 88, "pctUsed": 0.824, "eventCount": 37, "status": "warning" } Recall impact
/entities/{id}/recall-impactReadForward traceability: every lot that consumed {id}, directly or transitively.
Climbs the type’s primary composition plane upward — the inverse of structure. Each hit carries depth (1 = direct) and isRoot — true when nothing further consumes it, i.e. a finished, shippable lot a recall notice acts on. Generic over any type whose primary plane is a self-referential genealogy (a lot consuming lots). Trashed and change-request-staged lots are excluded; archived and draft lots still trace — the safe direction for a recall, since a phased-out lot that shipped must still surface. Empty when the record’s type has no composition plane.
{ "lots": [ { "lot": { ...entity }, "depth": 2, "isRoot": true } ] } Structure baselines
/entities/{id}/structure-baselinesReadLabeled frozen snapshots of this record’s BOM tree.
/entities/{id}/structure-baselines/{baselineId}ReadOne baseline, with the full-depth structure captured at freeze time.
A baseline freezes the whole tree, not a pointer to it, so it still answers "what did this look like then" after the components have moved on. Compare a baseline with the live tree — or two baselines with each other — to get the same line-by-line diff structure reads produce.
Revisions
/entities/{id}/revisionsReadThe record’s revision history, newest first.
/revisions/{id}ReadOne revision, with its frozen snapshot.
Every approved change welds an immutable revision recording what the record was, who signed it off and when. Revisions outlive the change request that produced them — an approved request is cleaned up, its revision is the history. Paged with before + limit.
Specs, calendars and quality
The PLM toolkit is reachable over the API, not only the UI. Each of these is configured: false rather than an error when the record’s type does not declare that plane, so a client can ask without knowing the schema first.
Measurements
/entities/{id}/size-specReadThe graded measurement chart: points of measure across the size range, with tolerances.
/entities/{id}/size-curveReadPer-size order quantities.
/entities/{id}/characteristicsReadThe record’s unsized measurable specification — the same idea as a size spec for something that is not graded.
Time & Action
/entities/{id}/ta-scheduleReadThe record’s Time & Action schedule: milestones counted back from the anchor date, with derived status.
Quality
/entities/{id}/checksReadCheck records against this record — rounds, verdicts and dispositions.
/checksReadEvery check in the project, filterable.
/check-kindsReadThe project’s check vocabulary.
Requirements
/entities/{id}/requirementsReadMaterial requirements for this order — the BOM exploded against its quantities.
For an order-free explosion of a single unit, use parts summary instead.
Counts and batch reads
/entities/by-idsReadBatch-fetch records by id, across any type, up to 200 per call.
/entities/countsReadRecord and collection-member totals, plus per-type record counts — the cheap way to watch plan usage.
/entities/{id}/reference-countReadHow many distinct live records reference this one — check before you delete.
/entities/{id}/alternate-usagesReadReverse approved-alternates: the BOM lines that list this record as an approved alternate.
/entities/{id}/activation-closureReadPreview the draft closure that activating this record would sweep in.
Receiving events
Rather than polling, have a project POST to you when something changes. Endpoints are set up in the admin app under Project → Webhooks.
There is no API for managing endpoints. Deliberately: an API key that could register a forwarding endpoint would be an escalation path out of every other limit placed on that key. Registering, pausing and rotating are admin actions.
Each endpoint gets a signing secret, shown once when it is created and replaceable by rotating. Store it as you would any credential — it is the only thing that distinguishes our POST from anyone else’s.
POST https://your-receiver.example.com/hook Content-Type: application/json X-ManyRows-Event: entity.change_approved X-ManyRows-Delivery: 0199f0e0-... # stable across retries — dedupe on this X-ManyRows-Timestamp: 1785628800 # unix seconds X-ManyRows-Signature: 9f86d081... # lowercase hex, see below
{
"event": "entity.change_approved",
"operation": "approve",
"entityId": "0199...",
"entityTypeId": "0199...",
"source": "admin",
"changes": [
{ "fieldKey": "status", "label": "Status", "type": "select",
"old": "In development", "new": "Approved" }
]
} changes carries each changed field’s before and after. Ids are not resolved to names — fetch the record when you need more than the diff.
Event list
entity.created entity.updated entity.trashed entity.restored entity.archived entity.unarchived entity.purged entity.activated entity.change_approved change_request.submitted change_request.rejected change_request.changes_requested
entity.change_approved is the one most integrations want: a governed change went live and a revision was welded for it.
Subscribing to nothing in particular means all events, including event types added later — so an endpoint registered today does not go quietly out of date.
Verifying signatures
Compute HMAC-SHA256(secret, "<timestamp>.<raw body>") and compare it in constant time against X-ManyRows-Signature. Use the raw body bytes, before any JSON parse or re-serialisation — re-encoding changes the bytes and the signature will not match.
import hmac, hashlib
def verify(secret, headers, raw_body):
ts = headers["X-ManyRows-Timestamp"]
sig = headers["X-ManyRows-Signature"]
mine = hmac.new(secret.encode(),
f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(mine, sig) The timestamp is inside the signed material, so a captured request cannot be replayed later. Reject anything whose timestamp is more than a few minutes old.
Delivery semantics
- At least once. A crash between our POST and our recording of your response redelivers.
X-ManyRows-Deliveryis stable across retries — dedupe on it. - Any 2xx is success. Everything else is retried, including 4xx: we cannot tell a permanent misconfiguration from a deploy briefly returning 404.
- Six attempts, backing off 30s → 2m → 8m → 32m → 2h, then given up on. A given-up delivery is kept and shown in the admin app rather than deleted — "we stopped trying to tell you" is the most useful thing that history can say.
- Respond fast. We wait 10 seconds. Acknowledge, then process asynchronously.
- Ordering is not guaranteed. Retries reorder events by design. If order matters, sort on your side or re-fetch the record.
- Pausing an endpoint stops delivery of its queued backlog too, and resumes it on re-activation. Events raised while it is paused are not backfilled.
Events are written in the same transaction as the change they describe, so nothing is announced that then rolls back.
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 / avif.
/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.
Client libraries
There are no official client libraries for the Data API yet. It is plain JSON over HTTP behind an X-API-Key header, so any HTTP client will do, and every example on this page runs as written.
Tell us what language you need and we can build it for you. Custom development is free on Pro and above, within reason, and it ships to everyone rather than sitting in a branch for one customer. Ask us.
Not yet available
No per-request Idempotency-Key — use upsert by referenceId for safe retries.
Writes to a record’s sub-resources — posting a measurement, completing a milestone — are read-only for now. That is "not yet", not "never".
Cost and pricing are admin-app only.