Skip to content

Endpoint reference

The tables below are a quick scan of the routes and filters. For full request/response schemas, the interactive explorer is the richer view.

The full machine-readable contract is served publicly (no auth, no shop data) at:

GET https://assemblified.com/api/v1/openapi.json

Point Postman, Insomnia, or any OpenAPI 3.1 client at that URL to generate a typed client or browse every parameter and response shape.

MethodPathScopeWhat it does
GET/api/v1/raw-materialsreadList raw materials (filters: search, usageFilter, originFilter, vendorFilter (repeatable), sortBy, sortOrder, page, pageSize)
GET/api/v1/raw-materials/{variantId}readOne material; ?includeConnections=true adds the BOMs + assembly bills that use it
PATCH/api/v1/raw-materials/{variantId}writeEdit fields — virtual (VMAT-*) materials only
GET/api/v1/raw-materials/{variantId}/inventoryreadReal per-location stock (?locationIds= repeatable) — live from Shopify for Shopify-linked materials
PATCH/api/v1/raw-materials/{variantId}/inventorywriteSet per-location stock — virtual (VMAT-*) materials only
GET/api/v1/raw-materials/{variantId}/residualreadPer-location fractional remainders (?locationIds= repeatable) — bookkeeping, not sellable stock
GET/api/v1/raw-materials/{variantId}/preassembledreadPer-location pre-assembled stock — finished units already built (?locationIds= repeatable)
GET/api/v1/raw-materials/{variantId}/commitmentsreadPer location, how much is spoken for: open order reservations and safety-stock holds, with who holds them (?locationIds= repeatable)
GET/api/v1/raw-materials/{variantId}/shelf-allocationreadPer location, how many units are embedded in pre-assembled stock on the shelf, with the parents that hold them (?locationIds= repeatable)
GET/api/v1/raw-materials/{variantId}/work-order-allocationreadPer location, how many units open work orders have picked and still have to pick, with the work orders behind it (?locationIds= repeatable)
GET/api/v1/bills-of-materialsreadList bills of materials (filters: search, statusFilter, sortBy, sortOrder, page, pageSize)
GET/api/v1/bills-of-materials/{id}readOne BOM with its fully hydrated recipe — raw materials, recursive assembly bills, cost factors
GET/api/v1/bills-of-materials/{id}/buildablereadThe computed per-location max-buildable (?locationIds= repeatable)
GET/api/v1/bills-of-materials/{id}/location-rulesreadThe per-location rules configured on the BOM: what it does at each location and which materials change there
GET/api/v1/assembly-bills/{id}/preassembledreadThe same read for an assembly bill, keyed by its assembly id
GET/api/v1/bill-versions/{ownerType}/{ownerId}readThe index of one bill’s saved versions (ownerType is bom or sub_assembly; ?limit= 1–20, ?offset=)
GET/api/v1/locationsreadYour Shopify locations — the id → name resolver every ?locationIds= keys on
GET/api/v1/assigneesreadThe people a work order can be assigned to (?includeInactive=true adds retired ones)
GET/api/v1/work-ordersreadList work orders with their filter facets (filters: q, status, locationId, assignedTo, sourceType, tags, sortBy, sortDir, page, pageSize)
GET/api/v1/work-orders/{id}readOne whole work order (?includeItemConsumption=true adds the per-item consumption plans)
GET/api/v1/work-orders/{id}/material-planreadIts material lines, the contributions behind them, the merged consumption plan and the cost block
GET/api/v1/work-orders/{id}/build-runsreadIts build-run history
PATCH/api/v1/work-orders/{id}writeEdit the header — requires expectedUpdatedAt
PATCH/api/v1/work-orders/{id}/transitionwriteMove it to another state — exactly one of to / direction, plus expectedUpdatedAt
PATCH/api/v1/work-orders/{id}/build-runswriteStart a build run — this moves real stock. Enhanced plan; send your own buildRunId
GET/api/v1/order-bom-breakdownreadBOM breakdowns for 1–50 orders in one call (?ids= repeatable and/or comma-separated) — partial success; ?locationId= computes them from a single location’s recipes
GET/api/v1/order-bom-breakdown/{id}readOne order’s BOM breakdown — what a warehouse must pack (?locationId= optional)
GET/api/v1/openapi.jsonpublicThe machine-readable spec — no auth required

sortBy on the list accepts productName (default), variantName, or inventoryQuantity; an unrecognized value is ignored and the default productName ordering is used.

Every endpoint that takes ?locationIds= expects the bare numeric location id — 101176672579, not the gid://shopify/Location/101176672579 form. GET /api/v1/locations is the resolver: it returns both spellings per location plus the name, and pages your whole location list (no cap). Two rules hold across all of them:

  • Omitting locationIds means every location — an empty list is the same as leaving it out.
  • A scope that matches nothing reports 0, never a shop-wide figure. If you ask “how many are pre-assembled at location X” and nothing is stocked there, the honest answer is zero at X.

Pre-assembled stock vs. residual remainders

Section titled “Pre-assembled stock vs. residual remainders”

Two per-location reads that are easy to confuse:

  • Pre-assembled (…/preassembled) is real, countable stock: finished units already built and sitting on the shelf, which an order consumes before any component is touched. Finished goods and assembly bills share one table for it, which is why the two routes return the same shape. Each route’s path id is the id that stock is tracked under, and it matches the namespace it sits in — a finished good is stocked against the Shopify variant it sells as, so read it at /raw-materials/{variantId}/preassembled (the same id its …/inventory and …/residual siblings take); an assembly bill is stocked against its assembly id, so read it at /assembly-bills/{id}/preassembled.
  • Residual (…/residual) is not stock at all. When a recipe consumes a fractional quantity, whole units are what Shopify can hold, so the leftover fraction is parked as a residual — three 0.4-unit builds add up to one whole unit plus 0.2 carried, instead of losing the fraction. Two kinds are reported side by side because they move independently: buildRun (work orders and order execution) and safetyStock (reservations). Never add residuals to an availability number.

Per-location commitment and allocation reads

Section titled “Per-location commitment and allocation reads”

Three reads answer “what is this material already spoken for?” from three different directions. None of them is availability, and none may be subtracted from a stock figure — use …/inventory for stock. All three take the same repeatable ?locationIds=, and all three return empty levels and zero totals for a material nothing touches (a valid answer, not an error).

RouteWhat it counts
…/commitmentsTwo numbers per location: orders — the open quantity of order reservations — and safetyStock — what active safety-stock reservations hold. Each level also names who holds the units: the orders behind the first, and the bill behind each safety-stock hold.
…/shelf-allocationHow many units are already built into pre-assembled stock sitting on a shelf, for every parent that consumes the material at any depth, waste included. Only available shelf units count — committed ones are reported by …/commitments, so nothing is double-counted. Each level lists the contributing parents.
…/work-order-allocationHow many units your open work orders have already picked (picked) and still have to pick (remaining), with allocated their sum. Open means every status except completed and cancelled. Each level lists the contributing work orders; demand from a work order with no location is reported separately under totals.unlocated.

The holder lists are capped at the 20 biggest per location, with a count of the rest — the sums stay uncapped, so a total is always the whole truth even when the list beside it is trimmed.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/raw-materials/VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f/work-order-allocation?locationIds=74001236152"

Shopify-managed materials are read-only in v1

Section titled “Shopify-managed materials are read-only in v1”

Every GET works for both origins — Shopify-linked and virtual. The two raw-material PATCH endpoints accept only virtual materials (ids starting VMAT-). A PATCH against a numeric Shopify variant id returns 400 INVALID_INPUT with a “read-only in API v1” message, before anything is changed. This is deliberate: fields like name, sku, price, and cost on Shopify-linked materials are mirrored from Shopify, so a local API edit would be silently reverted by the next product update. Editing those belongs in the Shopify admin (or a future API version with true write-back).

This rule is about materials. The three work-order PATCH endpoints take a work-order id and are not constrained by it — see Work orders below.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/raw-materials?originFilter=virtual&usageFilter=unused&pageSize=25"
{
"data": {
"items": [
{
"variantId": "VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f",
"productId": "VMAT-PROD-2a9c8b7d-6e5f-4a3b-8c1d-0e9f8a7b6c5d",
"inventoryItemId": "VMAT-INV-5c4d3e2f-1a0b-4c9d-8e7f-6a5b4c3d2e1f",
"productName": "Oak board 20 mm",
"variantName": "120 x 60 cm",
"sku": "OAK-20-120",
"vendor": "Holzwerk GmbH",
"unit": "pcs",
"unitCost": 12.5,
"unitPrice": 0,
"inventoryQuantity": 240,
"isVirtual": true,
"bomUsageCount": 0,
"subAssemblyUsageCount": 0
/* … specifications, imageUrl, isNonEssentialMaterial, weightValue,
weightUnitId, productType, productCategory … */
}
],
"totalCount": 7, "page": 1, "pageSize": 25, "totalPages": 1,
"availableVendors": ["Holzwerk GmbH", "Acme Supplies"]
},
"meta": { "requestId": "…", "apiVersion": "1.2" }
}
Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/raw-materials/VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f?includeConnections=true"

Returns data.material (same fields as a list row, minus the usage counts) plus data.connections with boms and subAssemblies arrays — each entry names the BOM / assembly bill, the quantity of this material it uses, and its location. Works for Shopify-linked materials too (numeric id in the path).

Terminal window
curl -s -X PATCH \
-H "Authorization: Bearer $ASMK_TOKEN" -H "Content-Type: application/json" \
-d '{ "unitCost": 13.1, "vendor": "Holzwerk GmbH" }' \
"https://assemblified.com/api/v1/raw-materials/VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f"

Returns data.material — the full updated row. Only the fields you send change; the editable field list is in Conventions.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/raw-materials/VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f/inventory"
{
"data": {
"variantId": "VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f",
"origin": "virtual",
"totalAvailable": 240,
"cachedQuantity": 240,
"levels": [
{ "locationId": "74001236152", "available": 190 },
{ "locationId": "74001268920", "available": 50 }
]
},
"meta": { "requestId": "…", "apiVersion": "1.2" }
}

For a Shopify-linked material (numeric id), origin is "shopify", the levels are a live Shopify read, and each level row additionally carries a locationName. Virtual materials return locationId + available only — no locationName.

Terminal window
curl -s -X PATCH \
-H "Authorization: Bearer $ASMK_TOKEN" -H "Content-Type: application/json" \
-d '{ "updates": [ { "locationId": "74001236152", "absolute": 200 } ] }' \
"https://assemblified.com/api/v1/raw-materials/VMAT-VAR-7f3d2c1a-9b4e-4c8d-a2f6-5e1b0d9c8a7f/inventory"

Returns the per-row batch envelope (results / okCount / failedCount / skippedCount). Each row carries exactly one of absolute (retry-safe — prefer it) or delta; full body semantics in Conventions. Every adjustment is recorded in the material’s adjustment history.

The four bills-of-materials endpoints expose your BOMsBill of MaterialsA bill of materials tells Assemblified how to build one unit of a finished good. When a customer orders the finished-good variant, Assemblified deducts the right component quantities from inventory automatically. Read more → — finished goods and their recipes, the computed max-buildable, and the per-location rules configured on a bill. They are read-only in this version.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/bills-of-materials?statusFilter=active&pageSize=25"

Returns data.items (the projected BOM rows) plus the pagination fields and a stats block (total, active, inactive, totalValue, avgCost, shopifyLinked) aggregated over the full BOM set.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/bills-of-materials/BOM-1748441466223"
{
"data": {
"bomId": "BOM-1748441466223",
"status": "active",
"name": "Oak Table",
"productId": "8613244518724", "variantId": "45217109934404",
"sku": "TABLE-OAK", "price": 499,
"totalRawMaterialCost": 96.5, "totalSubAssemblyCost": 41.0, "totalCost": 137.5,
"directCostFactorCost": 26.7,
"preAssembledQuantity": 3,
"rawMaterialReferences": [
{ "nodeId": "VMAT-VAR-…", "quantity": 4, "wastePercentage": 5,
"node": { "productName": "Oak board 20 mm", "unit": "pcs", "unitCost": 12.5 } }
],
"subAssemblyReferences": [
{ "nodeId": "ASM-…", "quantity": 2,
"node": { "assemblyName": "Table leg", "unitCost": 20.5,
"rawMaterialReferences": [], "subAssemblyReferences": [] } }
],
"costFactorReferences": [
{ "costFactorId": "CF-…", "quantity": 0.5,
"node": { "name": "Assembly labour", "kind": "labour", "unit": "hour", "unitCost": 40 } }
]
},
"meta": { "requestId": "…", "apiVersion": "1.2" }
}

Sub-assemblies nest recursively — each subAssemblyReferences[].node carries its own rawMaterialReferences and subAssemblyReferences, so one call returns the complete tree.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/bills-of-materials/BOM-1748441466223/buildable"
{
"data": {
"bomId": "BOM-1748441466223",
"variantId": "45217109934404",
"hasSellableSplit": false,
"perLocation": [
{ "locationId": "74001236152", "locationName": "Main warehouse",
"maxBuildable": 24, "sellable": 24,
"limitingComponents": [
{ "nodeId": "VMAT-VAR-…", "name": "Oak board 20 mm — 120 x 60 cm",
"origin": "virtual", "quantityPerUnit": 4, "wastePercentage": 5,
"isNonEssential": false, "maxUnitsByThisComponent": 24, "availableAtLocation": 96 }
] }
],
"totals": { "maxBuildable": 24, "sellable": 24 },
"globalFallback": false
},
"meta": { "requestId": "…", "apiVersion": "1.2" }
}

This is the same number the admin UI’s Max Buildable column shows: the full component tree against live Shopify raw-material levels, virtual levels, and nested pre-assembled assembly-bill stock — waste included, non-essential components excluded. Notes:

  • sellable respects assembly bills flagged only consume pre-assembled; it differs from maxBuildable only when hasSellableSplit is true.
  • limitingComponents lists the three tightest leaves of the SAME full-tree calculation that produced maxBuildable, most constraining first. A leaf can be a material anywhere in the recipe (including inside an assembly bill), and origin reads preassembled when a cut-off assembly bill’s pre-assembled stock is what binds (its nodeId is then subassembly:<assemblyId>). maxUnitsByThisComponent is that leaf’s exact capacity and equals maxBuildable on the first row; quantityPerUnit is the effective per-unit need, waste and nested consumption included.
  • When the BOM’s variant has no per-location Shopify data, totals falls back to a single global computation and globalFallback is true.
  • This is the most expensive read on the surface (it reads live Shopify levels) — cache the result rather than polling it.

The two order-bom-breakdown endpoints answer the warehouse question: given an order, what exactly needs to be packed? Each breakdown lists the order’s BOMBill of MaterialsA bill of materials tells Assemblified how to build one unit of a finished good. When a customer orders the finished-good variant, Assemblified deducts the right component quantities from inventory automatically. Read more → lines with their component materials — assembly billsAssembly billThe recipe for an intermediate component you build and then use inside other bills — a lid, a wick assembly, a pre-wired harness. It keeps its own pre-assembled stock and can be a work order item in its own right. Read more → expanded down to leaf raw materials, waste included, quantities already multiplied by the ordered quantity — plus a cross-order totalMaterials summary and the non-BOM lines under otherItems.

The boms and totalMaterials portions are the same shape as the assemblified.material_requirements_json order metafield (see Shopify extensions) — built by the same code — so anything already consuming the metafield can switch to this endpoint unchanged. Unlike the metafield, the endpoint works for every order (the metafield writeback is an opt-in setting) and always reflects the order’s current line items.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/order-bom-breakdown/6234567890123"
{
"data": {
"orderId": "6234567890123",
"orderName": "#1042",
"generatedAt": "2026-08-03T12:00:00.000Z",
"boms": [
{
"bomId": "BOM-1748441466223",
"bomName": "Oak Table",
"bomImageUrl": null,
"bomProductId": "8613244518724", "bomVariantId": "45217109934404",
"bomSku": "TABLE-OAK",
"orderQuantity": 2,
"materials": [
{ "productId": "VMAT-PROD-…", "variantId": "VMAT-VAR-…",
"sku": "OAK-20-120", "title": "Oak board 20 mm - 120 x 60 cm",
"productName": "Oak board 20 mm", "variantName": "120 x 60 cm",
"specifications": null, "notes": null,
"quantity": 8 }
]
}
],
"totalMaterials": [
{ "variantId": "VMAT-VAR-…", "productId": "VMAT-PROD-…",
"sku": "OAK-20-120", "title": "Oak board 20 mm - 120 x 60 cm",
"productName": "Oak board 20 mm", "variantName": "120 x 60 cm",
"specifications": null, "notes": null,
"totalQuantity": 8, "imageUrl": null,
"isVirtual": true, "unitCost": 12.5 }
],
"otherItems": [
{ "variantId": "45217109999999", "productId": "8613244599999",
"sku": "GIFT-CARD", "title": "Gift card", "quantity": 1 }
]
},
"meta": { "requestId": "…", "apiVersion": "1.2" }
}

The path id is a bare numeric Shopify order id (recommended). The full gid://shopify/Order/… form is accepted too, but must be percent-encoded when used in the path — its literal slashes would otherwise split the URL and return 404 (in the batch route’s ids query parameter it needs no encoding). materials[].quantity is already multiplied by orderQuantity (2 tables × 4 boards = 8); totalMaterials deduplicates across all BOM lines by variant. An order that contains no BOM products still returns 200 with boms: [] and its lines under otherItems — an unknown order id returns 404 NOT_FOUND.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/order-bom-breakdown?ids=6234567890123,6234567890124&ids=6234567890125"
{
"data": {
"items": [ { "orderId": "6234567890123", "…": "…" } ],
"notFound": ["6234567890125"]
},
"meta": { "requestId": "…", "apiVersion": "1.2" }
}

ids is repeatable and/or comma-separated (1–50 per call; more returns 400 INVALID_INPUT). The batch is partial-success: ids that can’t be resolved land in notFound and the call still returns 200 with every resolvable order — one request, one rate-limit token, regardless of how many orders it carries.

Eight routes — four reads and three writes, plus the assignee list they reference. This is the largest resource family in v1, and the only one with writes that are not virtual-material writes.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/work-orders?status=in_progress&pageSize=25"

Returns data.rows — flat rows, with no items, materials or build runs on them — plus total, hasMore and facets. The facets are the filter vocabulary for your shop counted over the unpaged result set (status, location, assignee and tag, each as { value, label, count }), so a client can render its filter controls without a second call.

Filters: q (free text over the name, the short id and the assignee’s name), status and tags (comma-separated, OR semantics), locationId, assignedTo (an assigneeId from GET /assignees, not a name), sourceType (flow, bom, sa, empty, orders). Sorting with sortBy + sortDir; paging with page (1-based) + pageSize (max 100).

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/work-orders/wo_9c3f21d8-5a4e-4d2b-91c7-0f6b5a3e7d14"

One work order, whole: the header, its items, materials and contributions, the merged consumption plan, build runs, QC reviews, rework items, documents, the conversation, the lifecycle events, the cost block and availableTransitions. Add ?includeItemConsumption=true for the per-item consumption plans (a second, heavier read).

warnings[] is recomputed on every read, never a stored flag — so a warning disappears the moment its cause does, and each one carries the remedy. That is the supported way to learn that a stock push is stuck; the build runs deliberately carry no internal sync columns.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/work-orders/wo_9c3f21d8-5a4e-4d2b-91c7-0f6b5a3e7d14/material-plan"

Returns materials, contributions (which recipe row or manual addition each quantity came from), consumptionPlan (what comes off which shelf at which location) and cost. This is the existing plan, not a preview — read it before starting a build run.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/work-orders/wo_9c3f21d8-5a4e-4d2b-91c7-0f6b5a3e7d14/build-runs"

Every pick, its items, the plan snapshot committed with it, and the completion or reversal that followed. Cheaper than reading the whole work order when the run history is all you want.

Terminal window
curl -s -X PATCH \
-H "Authorization: Bearer $ASMK_TOKEN" -H "Content-Type: application/json" \
-d '{ "expectedUpdatedAt": "2026-09-01T09:12:44.000Z", "priority": "high", "assigneeId": "3f7c1e90-2b84-4a15-9d63-8e0a4c5f2b71" }' \
"https://assemblified.com/api/v1/work-orders/wo_9c3f21d8-5a4e-4d2b-91c7-0f6b5a3e7d14"

Returns { workOrderId, updatedAt }. expectedUpdatedAt is required on all three writes — see Work-order PATCH semantics for the field lists and the optimistic-lock rule.

Terminal window
curl -s -X PATCH \
-H "Authorization: Bearer $ASMK_TOKEN" -H "Content-Type: application/json" \
-d '{ "expectedUpdatedAt": "2026-09-01T09:12:44.000Z", "direction": "forward" }' \
"https://assemblified.com/api/v1/work-orders/wo_9c3f21d8-5a4e-4d2b-91c7-0f6b5a3e7d14/transition"

Send exactly one of to (the target state) or direction (forward / back); both or neither is 400 INVALID_INPUT. The step itself is derived from the work order’s current state, so a client never needs the transition vocabulary. Returns { workOrderId, status, previousStatus, updatedAt, lifecycleEventId }. An illegal move is 409 CONFLICT with the blockers named.

Terminal window
curl -s -X PATCH \
-H "Authorization: Bearer $ASMK_TOKEN" -H "Content-Type: application/json" \
-d '{ "expectedUpdatedAt": "2026-09-01T09:12:44.000Z", "buildRunId": "8b1f…", "items": [ /* … */ ] }' \
"https://assemblified.com/api/v1/work-orders/wo_9c3f21d8-5a4e-4d2b-91c7-0f6b5a3e7d14/build-runs"

Returns the run with its items (buildRunId, shortId, status, the two updated-at tokens, and any negative-inventory warnings).

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" "https://assemblified.com/api/v1/assignees"

The people a work order can be assigned to, sorted by display name, each with usageCount (how many live work orders name them). Active rows only unless you pass ?includeInactive=true, which adds the retired ones — a retired person still resolves on the work orders that name them, but no new work order may be assigned to one.

These assigneeIds are what the work-order list’s assignedTo filter takes and what PATCH /work-orders/{id} writes to assigneeId. The list is read-only here; people are added and removed in the admin (Settings → Catalogues → Assignees) or through agent access.

Terminal window
curl -s -H "Authorization: Bearer $ASMK_TOKEN" \
"https://assemblified.com/api/v1/bill-versions/bom/BOM-1748441466223?limit=20"

The index of one bill’s saved versions, newest first. ownerType is bom for a finished good or sub_assembly for an assembly bill, and ownerId is that bill’s id — one index serves both kinds. Each row carries the change summary frozen onto it when it was saved (how many recipe fields, materials, per-location rules and overrides moved against the previous version), its content hash, what triggered it, and who saved it. Paging: limit (1–20) + offset, with hasMore.

pending counts versions that have been allocated but are not readable yet — which is why a page can hold fewer rows than you just saved.