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, 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

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, 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/query and /entities/validate. Every other POST (including /entities and 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.
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-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:

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. 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 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.

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

CodeHTTPMeaning
error.unauthorized401Missing / invalid / expired key, or wrong project
error.readOnlyKey403Write attempted with a read-only key
error.ipNotAllowed403Caller IP not in the key’s allowlist
error.trialExpired402No active subscription (billing configured)
error.tooManyRequests429Rate limit exceeded (see Retry-After)
error.internalError500Unexpected server-side failure — quote the X-Request-Id
not_found404No such record / type / route
invalid_json400Body was not parseable JSON
in_use409Referenced elsewhere; cannot proceed
cr_required409The type requires changes to go through a change request, see Propose changes
cr_locked409Record locked by an open change request (the body names it)
cr_not_author403Change request was not opened by this key
governed_trash_requires_reason409Trashing a governed, BOM-used record needs a reason
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)

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.

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" }
  ]
}

Catalogs & categories

Catalogs classify records into a category tree. Discover them, then filter /entities/query by a category (the categories field).

GET/catalogsRead

The project’s catalogs. Each targets one entity type or base type and owns a tree of categories.

GET/catalogs/{id}/categoriesRead

A catalog’s categories, flat. Nest by parentId (absent = a root). Each carries a rollup count — entities filed into it or any descendant.

GET/entities/{id}/categoriesRead

How 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.

shapesjson
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

POST/entities/queryRead

List 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

FieldTypeMeaning
type / baseTypestringScope to one entity-type key/id, or a base-type 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)
scopestringWhich 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
trashedbooleanLegacy shorthand for scope: "trashed". Don’t send both: trashed: true forces the Trash plane unless scope is "archived"
updatedAfterRFC3339Records modified strictly after this time (incremental sync)
effectiveOndateAs-of lens, YYYY-MM-DD or RFC3339: keep only records whose effectivity window covers that day (day-granular, both bounds inclusive)
notInCollectionuuidExclude records already in this collection (picker use)
unreferencedOnlybooleanRestrict to records no live record references — the "unused" view
categoriesarrayClassification filters, records filed into a catalog category (see below)
limitintegerPage size, default 50, max 200 (over-max clamps to 200)
offsetintegerPage offset (offset-based paging)
cursorstringOpaque keyset cursor for stable large-dataset iteration
countTotalbooleanDefault 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

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.

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:

categories entryjson
{ "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

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. 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 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)

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:

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/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

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

Add ?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 paramMeaning
idsComma-separated record ids — export exactly these
fieldsComma-separated field keys to limit the exported attributes (default all importable fields)
qSubstring match on name / reference id
filterRepeatable, colon-delimited key:op[:value] — the same operators as a query filters entry
matchall (default) or any
categoryRepeatable, colon-delimited: catalogId, catalogId:categoryId, or catalogId:categoryId:1 for that category and its subtree
unreferencedOnly1 / true for records nothing live references
effectiveOnAs-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.

POST/entities/importWrite

{ type, records:[...], draft? }, upsert by referenceId; ≤ 2000 records. Set draft: true to stage rows for review (imports into change-controlled types).

POST/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

POST/entities/import-bomWrite

Import 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.

json bodyjson
{
  "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

POST/entities/{id}/duplicateWrite

Deep-copy one record. The body selects what to carry over (an omitted/empty list copies none of that category).

duplicatejson
{
  "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.

GET/entities/{id}/activation-closureRead

Preview: { entities:[...] } that would activate together.

POST/entities/{id}/activateWrite

No 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:

the propose loophttp
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>.

GET/change-requestsRead

List the project’s change requests, paged (limit / offset; total is the full match count). Readable by any key — change requests are project data.

POST/change-requestsWrite

Open 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.

POST/change-requests/bulk-fieldWrite

The batch counterpart — one field set across many records (a nightly ECO run, a season rollover). Same propose-only semantics: created for review, not applied.

GET/change-requests/{id}Read

Read one change request; its status tells you whether your proposal is still in review.

POST/change-requests/{id}/submitWrite

Move 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.

DELETE/change-requests/{id}Write

Abandon 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):

GET/entities/{id}/collections/{fieldKey}/membersRead
POST/entities/{id}/collections/{fieldKey}/membersWrite
POST/entities/{id}/collections/{fieldKey}/members/bulk-deleteWrite

Remove several members at once: { "ids": [...] } (or { "all": true }).

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.

Parts summary (BOM explosion)

GET/entities/{id}/parts-summaryRead

Order-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.

shapejson
{ "state": "ready", "hasWastage": true,
  "rows": [ { materialId, materialName, materialRef,
              qtyPerUnit, qtyGross, unit, supplierId, supplierName } ] }

Rollups

GET/entities/{id}/rollupsRead

Evaluate 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.

shapejson
{ "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

GET/entities/{id}/usagesRead

Where-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.

shapejson
{ "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 stateactive, 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.

GET/entities/{id}/alternate-usagesRead

The 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

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. 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

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). Line writes return an ETag — send it back as If-Match to fail with 412 if the line changed since you read it.

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

Remove a line. Honours If-Match the same way.

POST/entities/{id}/composition/lines/{lineId}/promote-alternateWrite

Swap 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.

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" } }'

One component, many parents

POST/entities/bulk-add-composition-lineWrite

Add 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.

what was added, and why the rest were notjson
{ "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

POST/entities/{id}/substituteWrite

Replace 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.

blast radius (preview and apply)json
{ "affectedLines": 12, "affectedParents": 5, "mergedLines": 2,
  "revisionControlledParents": 3, "crLockedSkipped": 1,
  "parents": [ { id, name, referenceId, revisionControlled, lines, mergedLines } ] }

Component-wide edits

POST/entities/{id}/composition/bulk-editWrite

Remove 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.

blast radius (preview and apply)json
{ "op": "set_quantity", "affectedLines": 9, "affectedParents": 4,
  "revisionControlledParents": 2, "crLockedSkipped": 1,
  "nonLiveSkipped": 2, "outputLinesSkipped": 0,
  "parents": [ { id, name, referenceId, revisionControlled, lines } ] }

Life limits

GET/entities/{id}/lifeRead

Life 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.

shapejson
{ "configured": true, "limit": 500, "accumulated": 412,
  "remaining": 88, "pctUsed": 0.824, "eventCount": 37, "status": "warning" }

Recall impact

GET/entities/{id}/recall-impactRead

Forward 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.

shapejson
{ "lots": [ { "lot": { ...entity }, "depth": 2, "isRoot": true } ] }

Structure baselines

GET/entities/{id}/structure-baselinesRead

Labeled frozen snapshots of this record’s BOM tree.

GET/entities/{id}/structure-baselines/{baselineId}Read

One 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

GET/entities/{id}/revisionsRead

The record’s revision history, newest first.

GET/revisions/{id}Read

One 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

GET/entities/{id}/size-specRead

The graded measurement chart: points of measure across the size range, with tolerances.

GET/entities/{id}/size-curveRead

Per-size order quantities.

GET/entities/{id}/characteristicsRead

The record’s unsized measurable specification — the same idea as a size spec for something that is not graded.

Time & Action

GET/entities/{id}/ta-scheduleRead

The record’s Time & Action schedule: milestones counted back from the anchor date, with derived status.

Quality

GET/entities/{id}/checksRead

Check records against this record — rounds, verdicts and dispositions.

GET/checksRead

Every check in the project, filterable.

GET/check-kindsRead

The project’s check vocabulary.

Requirements

GET/entities/{id}/requirementsRead

Material 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

GET/entities/by-idsRead

Batch-fetch records by id, across any type, up to 200 per call.

GET/entities/countsRead

Record and collection-member totals, plus per-type record counts — the cheap way to watch plan usage.

GET/entities/{id}/reference-countRead

How many distinct live records reference this one — check before you delete.

GET/entities/{id}/alternate-usagesRead

Reverse approved-alternates: the BOM lines that list this record as an approved alternate.

GET/entities/{id}/activation-closureRead

Preview 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.

what arriveshttp
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
bodyjson
{
  "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

eventstext
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.

verify.pypython
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-Delivery is 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.

POST/assets/imagesWrite

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

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.

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.