{"openapi":"3.1.0","info":{"title":"Merchkit API","version":"1.0.0","description":"Operate your Merchkit PIM programmatically. Products, images, vendors, categories, and data sources are all \"entities\" in one uniform shape: system fields (`id`, `type`, `label`, `parent_id`, timestamps) at the top level and every customer-defined key — scalars AND references — in a sparse `attributes` map. References read as labeled stubs `{id, type, label}` and write as bare UUIDs or `{id}` objects (\"write what you read\"). UUIDs are the only path identifiers; find by your own key with a filter (`GET /products?filter[sku]=ARIA-DT-72`) or write by it with `POST /{resource}/upsert` and `/batch` (products, images, vendors, and categories only — sources have neither route; they are created by the async `POST /sources` job pipeline). Start every integration with `GET /attributes?type=product` to learn your keys. Completeness is per-channel. All errors share one envelope (see the `Error` schema). Authenticate with a workspace-scoped API key: `Authorization: Bearer mk_live_...`. See /llms.txt for an agent guide."},"servers":[{"url":"https://www.merchkit.com/api/v1","description":"Merchkit API v1"}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","description":"Workspace-scoped API key (`mk_live_...`). Each operation lists the scope it requires in `x-required-scopes`. See the Authentication guide for the full scope table and key management."}},"schemas":{"EntityResource":{"type":"object","description":"The one shape for every entity read and mutation response. System fields live at the top level; every customer-defined key lives in `attributes`, so customer keys can never collide with system ones.","required":["id","type","label","parent_id","created_at","updated_at","attributes"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["product","image","vendor","category","source"],"description":"The entity type discriminator."},"label":{"type":["string","null"],"description":"The workspace's primary-attribute value for this entity; null when no primary attribute is configured or it is unset (never a UUID posing as a name). Render `label ?? id`."},"parent_id":{"type":["string","null"],"format":"uuid","description":"Parent entity id (products: the variant parent)."},"created_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"},"attributes":{"type":"object","description":"SPARSE map of customer-defined keys — only keys with values appear; a missing key means unset or not-applicable (read `attributes[key] ?? null`; never contains null or \"\"). Scalars are bare values; a single reference is a labeled stub; a list reference is a COMPLETE ordered stub array (no cap, array order = position order). Inverse-reference keys never appear — traverse them via filters or `/products/{id}/references`. The full key list comes from `GET /attributes`.","additionalProperties":{"$ref":"#/components/schemas/AttributeValue"}}},"example":{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."},"category":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables"},"gallery_images":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg"},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg"},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg"},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg"},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg"}]}}},"AttributeValue":{"description":"A read value: a bare scalar (string/number/boolean), a single reference stub, or a complete ordered array of reference stubs.","oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"$ref":"#/components/schemas/ReferenceStub"},{"type":"array","items":{"$ref":"#/components/schemas/ReferenceStub"}}]},"ReferenceStub":{"type":"object","description":"A labeled pointer to another entity. Fetch the full record via `GET /{type plural}/{id}` when needed — the label is inline so list rendering needs no extra lookups.","required":["id","type","label"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["product","image","vendor","category","source"]},"label":{"type":["string","null"],"description":"The referenced entity's label (same null rule)."}},"example":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."}},"AttributeWriteMap":{"type":"object","description":"Map of attribute key → new value (sparse — only keys being written). Scalars set the value; `null` OR `\"\"` clears it (the key then disappears from reads). A single-reference key accepts a bare UUID string or an `{id}` object; a list-reference key accepts an array of either — list writes REPLACE the current set. Read stubs re-sent verbatim are valid writes (extra stub fields like `type`/`label` are ignored). Unknown keys are a 400; select values must be one of the attribute's `acceptable_values`.","additionalProperties":{"$ref":"#/components/schemas/AttributeWriteValue"}},"AttributeWriteValue":{"description":"A write value: scalar, null/\"\" to clear, a UUID or `{id}` object for a single reference, or an array of UUIDs/`{id}` objects for a list reference (replace semantics).","oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid"}},"additionalProperties":true,"description":"A reference by id — extra fields (a re-sent read stub's `type`/`label`) are ignored."},{"type":"array","items":{"oneOf":[{"type":"string","format":"uuid"},{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid"}},"additionalProperties":true}]}}]},"Pagination":{"type":"object","properties":{"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer","description":"The exact requested offset, echoed back."},"has_more":{"type":"boolean"}},"example":{"total":128,"limit":50,"offset":0,"has_more":true}},"EventPagination":{"type":"object","description":"Event feeds page differently: newest-first with a `next_since` checkpoint instead of total/offset.","properties":{"limit":{"type":"integer"},"has_more":{"type":"boolean","description":"True when the window exceeded one page — recover by state (`filter[updated_at][gte]=<checkpoint>`), not by paging."},"next_since":{"type":["string","null"],"format":"date-time","description":"The newest `created_at` on the page — your next `since` checkpoint (overlap by 1s and dedupe on event id)."}},"example":{"limit":50,"has_more":false,"next_since":"2026-07-18T09:30:22Z"}},"Event":{"type":"object","description":"One change event from the append-only log. New-value-only: there is no old value and no diff. Actor is `{kind}` only — no emails or key ids pass through the feed.","required":["id","type","entity_id","entity_type","attribute_key","value","actor","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["entity.created","entity.updated","entity.deleted"]},"entity_id":{"type":"string","format":"uuid"},"entity_type":{"type":["string","null"],"description":"Null once the entity has been deleted."},"attribute_key":{"type":["string","null"],"description":"The changed attribute for entity.updated; null otherwise."},"value":{"description":"The NEW value for entity.updated events; null otherwise. The log stores only what was written — no old values."},"actor":{"type":"object","required":["kind"],"properties":{"kind":{"type":"string","enum":["user","api_key","system"]}}},"created_at":{"type":"string","format":"date-time"}},"example":{"id":"e7c94b1f-2a68-4d05-9c3e-8b1f6a2d7c50","type":"entity.updated","entity_id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","entity_type":"product","attribute_key":"price","value":1299,"actor":{"kind":"api_key"},"created_at":"2026-07-18T09:30:22Z"}},"ReferencesPage":{"type":"object","description":"One reference attribute's membership, with positions.","required":["total","has_more","items"],"properties":{"total":{"type":"integer"},"has_more":{"type":"boolean"},"items":{"type":"array","items":{"type":"object","required":["id","type","label","position"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string"},"label":{"type":["string","null"]},"position":{"type":["integer","null"],"description":"Order within the reference list."}}}}}},"Completeness":{"type":"object","description":"A product's readiness for one channel.","required":["product_id","channel","is_complete","no_requirements","missing_count","required_missing_count","recommended_missing_count","invalid_count","issues"],"properties":{"product_id":{"type":"string","format":"uuid"},"channel":{"type":"string"},"is_complete":{"type":"boolean","description":"No missing (required or recommended) or invalid attributes."},"no_requirements":{"type":"boolean","description":"No rule makes any attribute required or recommended for this product (e.g. its category or class is empty), so `is_complete` says nothing about its readiness."},"missing_count":{"type":"integer","description":"required_missing_count + recommended_missing_count."},"required_missing_count":{"type":"integer"},"recommended_missing_count":{"type":"integer"},"invalid_count":{"type":"integer"},"issues":{"type":"array","items":{"type":"object","required":["key","name","status","current_value","requirement"],"properties":{"key":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","enum":["missing","invalid"]},"current_value":{"description":"The current (invalid) value; null when missing."},"requirement":{"type":"string","description":"Requirement level, e.g. `required` or `recommended`."},"acceptable_values":{"type":"array","items":{"type":"string"},"description":"Present for invalid issues with a value constraint — exactly what a write must match."}}}}}},"Job":{"type":"object","description":"An asynchronous operation. Poll until a terminal status.","required":["id","name","status","progress","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"status":{"type":"string","enum":["pending","running","completed","failed","cancelled"]},"progress":{"type":"object","properties":{"current":{"type":"integer"},"total":{"type":["integer","null"]},"percent":{"type":"number"}}},"created_at":{"type":"string","format":"date-time"},"started_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"result":{"type":"null","description":"Reserved for future async write results; always null today."},"metadata":{"type":["object","null"]}},"example":{"id":"1f7c9a2b-6d43-4e8f-b501-a92c7d3e6f18","name":"Process Source","status":"running","progress":{"current":40,"total":100,"percent":40},"created_at":"2026-07-18T09:30:22Z","started_at":"2026-07-18T09:30:22Z","completed_at":null,"result":null,"metadata":{"workflow":"process-source","url":"https://nordictimber.example/catalog/aria-oak-dining-table","via":"api_v1"}}},"BatchSummary":{"type":"object","description":"Batch-level counts. `has_errors` is the cheap \"anything to look at?\" check; clean batches can skip the per-item scan. Do not sum summaries across idempotent re-runs of the same batch.","required":["total","created","updated","failed","has_errors"],"properties":{"total":{"type":"integer"},"created":{"type":"integer"},"updated":{"type":"integer"},"failed":{"type":"integer"},"has_errors":{"type":"boolean"}},"example":{"total":3,"created":1,"updated":1,"failed":1,"has_errors":true}},"BatchItemResult":{"type":"object","description":"Per-item outcome of a batch write, in input order. Always check every item — the batch HTTP status only says the batch ran. `merge_value` echoes the item's merge-key value so results stay joinable to source rows without the original request (and across re-sliced batches).","required":["index","status"],"properties":{"index":{"type":"integer","description":"Position in the request `items`."},"merge_value":{"type":"string","description":"Trimmed echo of `attributes[merge_key]` (present when the batch has a merge_key)."},"id":{"type":"string","format":"uuid","description":"The created/updated entity id (absent on error)."},"status":{"type":"string","enum":["created","updated","error"]},"error":{"type":"object","required":["code","retriable","message"],"properties":{"code":{"type":"string","description":"Platform error code (same vocabulary as the top-level error envelope). Fine-grained causes are in field_errors[].issue."},"retriable":{"type":"boolean","description":"Whether re-submitting the same item unchanged can succeed. Route retry queues on this, never on message text."},"message":{"type":"string"},"field_errors":{"type":"array","items":{"$ref":"#/components/schemas/FieldError"}}}}},"example":{"index":0,"merge_value":"ARIA-DT-72","id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","status":"updated"}},"AttributeDefinition":{"type":"object","description":"The discovery projection of an attribute definition (`GET /attributes`): what the key is, whether you may write it, and what values it accepts.","required":["id","key","name","entity_type","data_type","channel","is_primary","writable","acceptable_values","default_value","formula_expression"],"properties":{"id":{"type":"string","format":"uuid"},"key":{"type":"string","description":"The key used in `attributes`, filters, and sorts."},"name":{"type":"string"},"entity_type":{"type":"string"},"data_type":{"type":"string"},"channel":{"type":["string","null"],"description":"Set when the attribute is channel-scoped."},"is_primary":{"type":"boolean","description":"The primary attribute supplies every entity's `label`."},"writable":{"type":"boolean","description":"False for derived types (formula, inverse_reference, reference_lookup) — writes to them are rejected."},"acceptable_values":{"type":"array","items":{"type":"string"},"description":"Parsed allowed values for selects; empty when unconstrained."},"default_value":{"type":["string","null"]},"formula_expression":{"type":["string","null"],"description":"The template of a formula or web_preview column; null for every other attribute."}},"example":{"id":"a3d81c22-5f6e-4b09-8d17-c40a92e5b613","key":"material","name":"Material","entity_type":"product","data_type":"single_select_list","channel":null,"is_primary":false,"writable":true,"acceptable_values":["White Oak","Smoked Oak","Walnut"],"default_value":null,"formula_expression":null}},"AttributeDefinitionDetail":{"type":"object","description":"The full attribute-definition config returned by create/update — a superset of the discovery projection (raw `acceptable_values` string, generation `tool`, `constraints`).","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"key":{"type":"string"},"data_type":{"type":"string"},"tool":{"type":"string","description":"Generation tool, e.g. `manual`, `prompt`, `image-generation`, `formula`."},"entity_type":{"type":"string"},"channel":{"type":["string","null"]},"is_primary":{"type":"boolean"},"custom_instructions":{"type":["string","null"]},"acceptable_values":{"type":["string","null"],"description":"The raw comma-separated string as stored."},"default_value":{"type":["string","null"]},"constraints":{"type":"object","description":"Generation/inheritance config (model, tools, source keys)."},"source_attributes":{"type":["array","null"],"items":{"type":"object","properties":{"id":{"type":"string"},"key":{"type":"string"},"name":{"type":"string"}}}}},"example":{"id":"a3d81c22-5f6e-4b09-8d17-c40a92e5b613","name":"Material","key":"material","data_type":"single_select_list","tool":"manual","entity_type":"product","channel":null,"is_primary":false,"custom_instructions":null,"acceptable_values":"White Oak,Smoked Oak,Walnut","default_value":null,"constraints":{},"source_attributes":null}},"AttributeDefinitionCreate":{"type":"object","description":"Create body (snake_case, like every write body). `single_select_list`/`multi_select_list` require `acceptable_values`.","required":["name","entity_type","data_type","ai_generated"],"additionalProperties":false,"properties":{"name":{"type":"string"},"entity_type":{"type":"string"},"data_type":{"type":"string","enum":["single_line_text_field","multi_line_text_field","rich_text_field","number","boolean","date","url","json","single_select_list","multi_select_list","image","video","formula","web_preview","status","progress","rank"]},"ai_generated":{"type":"boolean"},"custom_instructions":{"type":"string","description":"Generation prompt; reference other attributes with {{key}}."},"formula_expression":{"type":"string","description":"Template of a computed column: a formula expression, or an HTML template with `data_type: web_preview`. Makes the attribute computed (not AI-generated)."},"acceptable_values":{"type":"string","description":"Comma-separated allowed values for select attributes."},"default_value":{"type":"string"},"is_primary":{"type":"boolean"},"selected_tools":{"type":"array","items":{"type":"string","enum":["includeDataSources","includeImages","includeWebSearch","includeProductAttributes"]},"description":"AI text-generation tools. includeWebSearch = live web research; includeDataSources = scraped sites/CSVs; includeImages = vision."},"source_attribute_keys":{"type":"array","items":{"type":"string"}},"required_dependencies":{"type":"array","items":{"type":"string"}},"image_model":{"type":"string","enum":["gpt-image-1","nano-banana"]},"image_source_attribute_key":{"type":"array","items":{"type":"string"}},"aspect_ratio":{"type":"string","enum":["1:1","4:3","3:4","16:9","9:16"],"description":"Image generation aspect ratio. Legacy values (`Square`→`1:1`, `Landscape`→`16:9`, `Portrait`→`9:16`) are still accepted for backward compatibility but are deprecated."},"video_model":{"type":"string","enum":["veo3.1"]},"video_source_attribute_key":{"type":"array","items":{"type":"string"}},"vision_image_source_attribute_key":{"type":"array","items":{"type":"string"}},"inheritance_mode":{"type":"string","enum":["off","inherit_from_parent","rollup_from_children"],"description":"Parent/child variant inheritance (products only; ignored for image/video attributes). `inherit_from_parent` = variants display the parent's value; `rollup_from_children` = the parent displays its variants' combined values; `off` removes inheritance. Virtualized at read time."},"rollup_aggregation":{"type":"string","enum":["unique","all"],"description":"Only with `rollup_from_children`. `unique` = distinct values joined; `all` = one entry per variant. Default `unique`."}}},"AttributeDefinitionUpdate":{"type":"object","description":"Update body — the attribute is identified by the path key + `entity_type`. Only provided fields change; array fields replace wholesale. Accepts the same config fields as create (all optional except `entity_type`).","required":["entity_type"],"additionalProperties":false,"properties":{"entity_type":{"type":"string"},"name":{"type":"string"},"data_type":{"type":"string","enum":["single_line_text_field","multi_line_text_field","rich_text_field","number","boolean","date","url","json","single_select_list","multi_select_list","image","video","formula","web_preview","status","progress","rank"]},"ai_generated":{"type":"boolean"},"custom_instructions":{"type":"string"},"formula_expression":{"type":"string","description":"New template of a computed column. A formula or web_preview attribute keeps its data type; any other attribute becomes a formula. Recomputes every row."},"acceptable_values":{"type":"string"},"default_value":{"type":"string"},"is_primary":{"type":"boolean"},"selected_tools":{"type":"array","items":{"type":"string","enum":["includeDataSources","includeImages","includeWebSearch","includeProductAttributes"]}},"source_attribute_keys":{"type":"array","items":{"type":"string"}},"required_dependencies":{"type":"array","items":{"type":"string"}},"image_model":{"type":"string","enum":["gpt-image-1","nano-banana"]},"image_source_attribute_key":{"type":"array","items":{"type":"string"}},"aspect_ratio":{"type":"string","enum":["1:1","4:3","3:4","16:9","9:16"],"description":"Image generation aspect ratio. Legacy values (`Square`→`1:1`, `Landscape`→`16:9`, `Portrait`→`9:16`) are still accepted for backward compatibility but are deprecated."},"video_model":{"type":"string","enum":["veo3.1"]},"video_source_attribute_key":{"type":"array","items":{"type":"string"}},"vision_image_source_attribute_key":{"type":"array","items":{"type":"string"}},"inheritance_mode":{"type":"string","enum":["off","inherit_from_parent","rollup_from_children"]},"rollup_aggregation":{"type":"string","enum":["unique","all"]}}},"Channel":{"type":"object","description":"A marketplace/sales channel enabled for this workspace.","required":["id","name","entityType"],"properties":{"id":{"type":"string","description":"Channel id — the value completeness and attribute scoping take."},"name":{"type":"string"},"description":{"type":"string"},"entityType":{"type":"string","description":"The entity type the channel applies to (typically `product`)."}},"example":{"id":"wayfair","name":"Wayfair","description":"Wayfair marketplace listings","entityType":"product"}},"FieldError":{"type":"object","required":["field","issue"],"properties":{"field":{"type":"string"},"issue":{"type":"string","description":"Machine-readable issue code, e.g. `not_a_defined_attribute`."},"acceptable_values":{"type":"array","items":{"type":"string"},"description":"For select-value violations: the allowed values."}}},"Error":{"type":"object","description":"The one error envelope every non-2xx response uses. `documentation_url` links to the code's section of the error docs.","required":["error"],"properties":{"error":{"type":"object","required":["code","message","is_retriable"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","forbidden","not_found","validation_failed","rate_limited","conflict","internal_error"]},"message":{"type":"string"},"documentation_url":{"type":"string","format":"uri","description":"https://docs.merchkit.com/developers/errors#<code>"},"is_retriable":{"type":"boolean","description":"Only `rate_limited` and `internal_error` are retriable. Deterministic client errors (validation, not-found, conflict) return a 4xx and are never retriable."},"retry_after_seconds":{"type":["integer","null"]},"alternative_action":{"type":"string","description":"What to do instead, when there is a better call."},"request_id":{"type":"string"},"field_errors":{"type":"array","items":{"$ref":"#/components/schemas/FieldError"}}}}},"example":{"error":{"code":"validation_failed","message":"Unknown attribute key(s): colour.","documentation_url":"https://docs.merchkit.com/developers/errors#validation_failed","is_retriable":false,"retry_after_seconds":null,"alternative_action":"List valid attribute keys via GET /v1/attributes?type=product.","request_id":"req_01j9x2k8","field_errors":[{"field":"colour","issue":"not_a_defined_attribute"}]}}}}},"paths":{"/products":{"get":{"operationId":"listProducts","summary":"List products","description":"Returns a paginated page of products in the one-map resource shape (system fields top-level, every customer key under `attributes`). Reference attributes embed COMPLETE as labeled stubs — no expansion calls; inverse-reference keys never appear (query the other side via filters, or page `/products/{id}/references`). Filter with `filter[<key>]=<value>` / `filter[<key>][<op>]=<value>` (see the `filter` parameter for the grammar), sort with `sort`, page with `limit`/`offset`, and narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:products"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."},{"name":"sort","in":"query","schema":{"type":"string"},"description":"Comma-separated keys. Prefix with `-` for descending:\n`sort=-updated_at,sku`. Defaults to `created_at` ascending.\n\nSortable: text, number, and date attributes, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference and\nlist-reference keys have no scalar order. Sorting on one returns\n`400` (`not_sortable`)."},{"name":"filter","in":"query","style":"deepObject","explode":true,"schema":{"type":"object","additionalProperties":{"type":"string"}},"description":"Narrow results by attribute value. Filtering runs in the database,\nso `pagination.total` reflects matches, not the whole catalog.\n\nTwo forms: `filter[sku]=ARIA-DT-72` is shorthand for equals.\n`filter[price][gte]=100` uses the bracket-operator form\n`filter[<key>][<op>]=<value>`.\n\nKeys: any attribute key from `GET /attributes`, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference keys like\n`vendor` match on UUID, not name.\n\nOperators:\n\n- Text: `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`\n- Number, date, timestamp: `gt`, `gte`, `lt`, `lte`. A bare date means UTC midnight.\n- Any type: `exists` (`=true` has a value, `=false` is blank)\n- Text, `id`, and reference keys: `in` (comma-separated OR-of-equals, up to 200 values)\n\nExamples:\n\n- `filter[sku]=ARIA-DT-72` one product by your own identifier\n- `filter[description][exists]=false` everything missing a description\n- `filter[price][gte]=100&filter[material]=Oak` both must match (see `filter_join`)\n- `filter[id][in]=<uuid1>,<uuid2>` refetch a known set in one call\n- `filter[vendor]=<uuid>&sort=-updated_at` one vendor's catalog, newest first\n\nA bad filter never silently matches everything. Unknown keys, unknown operators,\nor type mismatches return `400` with `field_errors` naming the exact clause.\n\nBrackets and spaces need URL-encoding. With `curl`, use\n`-G --data-urlencode \"filter[vendor_name]=Nordic Timber Co.\"`.\nIn JavaScript, let `URLSearchParams` handle it."},{"name":"filter_join","in":"query","schema":{"type":"string","enum":["and","or"],"default":"and"},"description":"How multiple `filter` clauses combine. `and` means every clause\nmust match (the default). `or` means any clause can match.\nCase-insensitive. Any other value returns `400`.\n\nOnly matters with two or more clauses. One join applies to all of\nthem. There is no grouping or nesting, so mixed AND/OR logic\nbelongs on your side.\n\nFor OR across values of the same key, prefer\n`filter[<key>][in]=a,b,c`. One clause, up to 200 values, and it\nreads better in a log."},{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"x-codeSamples":[{"lang":"bash","label":"Find one product by sku","source":"curl -G \"https://www.merchkit.com/api/v1/products\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"filter[sku]=ARIA-DT-72\"\n\n# -G with --data-urlencode is the reliable shape: curl encodes the\n# brackets and any spaces in the value, so the query arrives intact."},{"lang":"bash","label":"Read a full page, only the keys you need","source":"curl -G \"https://www.merchkit.com/api/v1/products\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"fields=sku,price\" \\\n  --data-urlencode \"limit=200\"\n\n# `fields` trims the attributes map; 200 is the maximum page size."},{"lang":"javascript","label":"Page through everything changed since a date","source":"// URLSearchParams handles the bracket encoding for you.\nconst params = new URLSearchParams({\n  \"filter[updated_at][gte]\": \"2026-08-01\",\n  fields: \"sku,price\",\n  limit: \"200\",\n});\n\nconst products = [];\nlet offset = 0;\n\nwhile (true) {\n  params.set(\"offset\", String(offset));\n\n  const res = await fetch(`https://www.merchkit.com/api/v1/products?${params}`, {\n    headers: { Authorization: `Bearer ${process.env.MERCHKIT_API_KEY}` },\n  });\n\n  const body = await res.json();\n  if (!res.ok) throw new Error(body.error.message);\n\n  products.push(...body.data);\n\n  // Trust has_more rather than comparing counts yourself.\n  if (!body.pagination.has_more) break;\n  offset += body.pagination.limit;\n}"}],"responses":{"200":{"description":"A page of products.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EntityResource"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","price":1299}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}},"400":{"description":"Invalid filter, sort, or paging parameter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createProduct","summary":"Create a product","description":"Creates a product and returns the full resource — no follow-up GET needed. `attributes` uses the same one-map write shape as PATCH (references inline as UUIDs or `{id}` stubs). Unknown attribute keys are rejected with 400.\n\nScope: `write:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:products"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"parent_id":{"type":"string","format":"uuid","description":"Optional parent product (products: creates a variant)."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","category":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","gallery_images":["f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f"]}}}}},"responses":{"201":{"description":"The created product (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."},"category":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables"},"gallery_images":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg"},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg"},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg"},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg"},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg"}]}}}}}},"400":{"description":"Validation failed (unknown attribute key, bad value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getProduct","summary":"Get a product","description":"Full detail, same shape as the list rows. `label` is the workspace's primary-attribute value (render `label ?? id`); `attributes` is sparse — a missing key means unset or not-applicable (read `attributes[key] ?? null`). Narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:products"],"parameters":[{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"responses":{"200":{"description":"The product.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."},"category":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables"},"gallery_images":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg"},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg"},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg"},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg"},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg"}]}}}}}},"404":{"description":"Not found — ids outside your workspace are indistinguishable from missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateProduct","summary":"Update a product","description":"THE one write path: set attribute values and/or move the parent in a single call (provide `parent_id`, `attributes`, or both). Writes are symmetric with reads — a GET response's `attributes` re-sent verbatim is valid (`{id, type, label}` stubs are accepted; extra stub fields ignored). `null` or `\"\"` clears a value (the key then disappears from reads); list-reference writes REPLACE the current set. Returns the full updated resource.\n\nScope: `write:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:products"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"parent_id":{"type":["string","null"],"format":"uuid","description":"New parent id, or null to detach."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"price":1349,"material":"Smoked Oak"}}}}},"responses":{"200":{"description":"The updated product (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."},"category":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables"},"gallery_images":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg"},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg"},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg"},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg"},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg"}]}}}}}},"400":{"description":"Validation failed (unknown key, bad value, self-parenting).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/upsert":{"post":{"operationId":"upsertProduct","summary":"Upsert a product by your own key","description":"Create-or-update by a customer-named merge key: `merge_key` names any defined text-family attribute (usually `sku`) and `attributes[merge_key]` supplies the value to match — trimmed, case-insensitive. No match → create (201); one match → update (200); more than one match → 409 listing the matching ids. Idempotent for ERP-style pushes: address entities by your key, no UUID mapping table needed.\n\nScope: `write:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:products"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["merge_key","attributes"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"A defined text-family attribute key to match on (e.g. `sku`)."},"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"merge_key":"sku","attributes":{"sku":"ARIA-DT-72","price":1349}}}}},"responses":{"200":{"description":"An existing product matched and was updated (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."},"category":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables"},"gallery_images":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg"},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg"},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg"},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg"},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg"}]}}}}}},"201":{"description":"No match — a product was created (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","type":"product","label":"Aria Oak Dining Table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-72","product_name":"Aria Oak Dining Table","price":1299,"material":"White Oak","vendor":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co."},"category":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables"},"gallery_images":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg"},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg"},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg"},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg"},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg"}]}}}}}},"400":{"description":"Validation failed (unknown merge_key/attribute, missing merge value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"More than one existing match — deduplicate or update by id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/batch":{"post":{"operationId":"batchWriteProducts","summary":"Batch create/upsert products","description":"Up to 100 items in one call. With `merge_key`, every item gets upsert semantics (create-or-update by that key); without it, pure creates. Items are processed independently and the response is PER-ITEM: **HTTP 200 does NOT mean every item succeeded** — check each `status` (`created` | `updated` | `error`) and surface `error.message`/`error.field_errors` for failures. A 5,000-SKU ERP delta push is 50 calls with `merge_key: \"sku\"`.\n\nScope: `write:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:products"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"Optional shared merge key — when present, items upsert instead of create."},"items":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["attributes"],"additionalProperties":false,"properties":{"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}}}}},"example":{"merge_key":"sku","items":[{"attributes":{"sku":"ARIA-DT-72","price":1349}},{"attributes":{"sku":"ARIA-DT-96","product_name":"Aria Oak Dining Table 96\"","price":1699}},{"attributes":{"sku":"ARIA-DT-120","pricee":2199}}]}}}},"responses":{"200":{"description":"Per-item results, in input order, under a `summary` that makes \"N of M failed\" a single field read. Check every item — a 200 only means the batch was processed. Route retries on `error.retriable`, never on message text. Summaries from re-runs must not be summed (an idempotent retry reports updates for rows the first attempt created).","content":{"application/json":{"schema":{"type":"object","required":["summary","data"],"properties":{"summary":{"$ref":"#/components/schemas/BatchSummary"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BatchItemResult"}}}},"example":{"summary":{"total":3,"created":1,"updated":1,"failed":1,"has_errors":true},"data":[{"index":0,"merge_value":"ARIA-DT-72","id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","status":"updated"},{"index":1,"merge_value":"ARIA-DT-96","id":"3c77a1de-52b9-4f0c-8a4e-91d2c6b7e803","status":"created"},{"index":2,"merge_value":"ARIA-DT-120","status":"error","error":{"code":"validation_failed","retriable":false,"message":"Unknown attribute key(s): pricee.","field_errors":[{"field":"pricee","issue":"not_a_defined_attribute"}]}}]}}}},"400":{"description":"Malformed batch (empty items, >100 items, bad shape).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/images":{"get":{"operationId":"listImages","summary":"List images","description":"Returns a paginated page of images in the one-map resource shape (system fields top-level, every customer key under `attributes`). Reference attributes embed COMPLETE as labeled stubs — no expansion calls; inverse-reference keys never appear (query the other side via filters, or page `/products/{id}/references`). Filter with `filter[<key>]=<value>` / `filter[<key>][<op>]=<value>` (see the `filter` parameter for the grammar), sort with `sort`, page with `limit`/`offset`, and narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:images`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:images"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."},{"name":"sort","in":"query","schema":{"type":"string"},"description":"Comma-separated keys. Prefix with `-` for descending:\n`sort=-updated_at,sku`. Defaults to `created_at` ascending.\n\nSortable: text, number, and date attributes, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference and\nlist-reference keys have no scalar order. Sorting on one returns\n`400` (`not_sortable`)."},{"name":"filter","in":"query","style":"deepObject","explode":true,"schema":{"type":"object","additionalProperties":{"type":"string"}},"description":"Narrow results by attribute value. Filtering runs in the database,\nso `pagination.total` reflects matches, not the whole catalog.\n\nTwo forms: `filter[sku]=ARIA-DT-72` is shorthand for equals.\n`filter[price][gte]=100` uses the bracket-operator form\n`filter[<key>][<op>]=<value>`.\n\nKeys: any attribute key from `GET /attributes`, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference keys like\n`vendor` match on UUID, not name.\n\nOperators:\n\n- Text: `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`\n- Number, date, timestamp: `gt`, `gte`, `lt`, `lte`. A bare date means UTC midnight.\n- Any type: `exists` (`=true` has a value, `=false` is blank)\n- Text, `id`, and reference keys: `in` (comma-separated OR-of-equals, up to 200 values)\n\nExamples:\n\n- `filter[sku]=ARIA-DT-72` one product by your own identifier\n- `filter[description][exists]=false` everything missing a description\n- `filter[price][gte]=100&filter[material]=Oak` both must match (see `filter_join`)\n- `filter[id][in]=<uuid1>,<uuid2>` refetch a known set in one call\n- `filter[vendor]=<uuid>&sort=-updated_at` one vendor's catalog, newest first\n\nA bad filter never silently matches everything. Unknown keys, unknown operators,\nor type mismatches return `400` with `field_errors` naming the exact clause.\n\nBrackets and spaces need URL-encoding. With `curl`, use\n`-G --data-urlencode \"filter[vendor_name]=Nordic Timber Co.\"`.\nIn JavaScript, let `URLSearchParams` handle it."},{"name":"filter_join","in":"query","schema":{"type":"string","enum":["and","or"],"default":"and"},"description":"How multiple `filter` clauses combine. `and` means every clause\nmust match (the default). `or` means any clause can match.\nCase-insensitive. Any other value returns `400`.\n\nOnly matters with two or more clauses. One join applies to all of\nthem. There is no grouping or nesting, so mixed AND/OR logic\nbelongs on your side.\n\nFor OR across values of the same key, prefer\n`filter[<key>][in]=a,b,c`. One clause, up to 200 values, and it\nreads better in a log."},{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"x-codeSamples":[{"lang":"bash","label":"Find one image by file_name","source":"curl -G \"https://www.merchkit.com/api/v1/images\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"filter[file_name]=aria-hero.jpg\"\n\n# -G with --data-urlencode is the reliable shape: curl encodes the\n# brackets and any spaces in the value, so the query arrives intact."},{"lang":"bash","label":"Read a full page, only the keys you need","source":"curl -G \"https://www.merchkit.com/api/v1/images\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"fields=file_name,image_url\" \\\n  --data-urlencode \"limit=200\"\n\n# `fields` trims the attributes map; 200 is the maximum page size."},{"lang":"javascript","label":"Page through everything changed since a date","source":"// URLSearchParams handles the bracket encoding for you.\nconst params = new URLSearchParams({\n  \"filter[updated_at][gte]\": \"2026-08-01\",\n  fields: \"file_name,image_url\",\n  limit: \"200\",\n});\n\nconst images = [];\nlet offset = 0;\n\nwhile (true) {\n  params.set(\"offset\", String(offset));\n\n  const res = await fetch(`https://www.merchkit.com/api/v1/images?${params}`, {\n    headers: { Authorization: `Bearer ${process.env.MERCHKIT_API_KEY}` },\n  });\n\n  const body = await res.json();\n  if (!res.ok) throw new Error(body.error.message);\n\n  images.push(...body.data);\n\n  // Trust has_more rather than comparing counts yourself.\n  if (!body.pagination.has_more) break;\n  offset += body.pagination.limit;\n}"}],"responses":{"200":{"description":"A page of images.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EntityResource"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg"}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}},"400":{"description":"Invalid filter, sort, or paging parameter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createImage","summary":"Create an image","description":"Creates an image and returns the full resource — no follow-up GET needed. `attributes` uses the same one-map write shape as PATCH (references inline as UUIDs or `{id}` stubs). Unknown attribute keys are rejected with 400.\n\nScope: `write:images`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:images"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"parent_id":{"type":"string","format":"uuid","description":"Optional parent image (products: creates a variant)."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}},"responses":{"201":{"description":"The created image (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}}},"400":{"description":"Validation failed (unknown attribute key, bad value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/images/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getImage","summary":"Get an image","description":"Full detail, same shape as the list rows. `label` is the workspace's primary-attribute value (render `label ?? id`); `attributes` is sparse — a missing key means unset or not-applicable (read `attributes[key] ?? null`). Narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:images`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:images"],"parameters":[{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"responses":{"200":{"description":"The image.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}}},"404":{"description":"Not found — ids outside your workspace are indistinguishable from missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateImage","summary":"Update an image","description":"THE one write path: set attribute values and/or move the parent in a single call (provide `parent_id`, `attributes`, or both). Writes are symmetric with reads — a GET response's `attributes` re-sent verbatim is valid (`{id, type, label}` stubs are accepted; extra stub fields ignored). `null` or `\"\"` clears a value (the key then disappears from reads); list-reference writes REPLACE the current set. Returns the full updated resource.\n\nScope: `write:images`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:images"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"parent_id":{"type":["string","null"],"format":"uuid","description":"New parent id, or null to detach."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"alt_text":"Aria Oak Dining Table — hero, studio light"}}}}},"responses":{"200":{"description":"The updated image (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}}},"400":{"description":"Validation failed (unknown key, bad value, self-parenting).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/images/upsert":{"post":{"operationId":"upsertImage","summary":"Upsert an image by your own key","description":"Create-or-update by a customer-named merge key: `merge_key` names any defined text-family attribute (usually `sku`) and `attributes[merge_key]` supplies the value to match — trimmed, case-insensitive. No match → create (201); one match → update (200); more than one match → 409 listing the matching ids. Idempotent for ERP-style pushes: address entities by your key, no UUID mapping table needed.\n\nScope: `write:images`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:images"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["merge_key","attributes"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"A defined text-family attribute key to match on (e.g. `sku`)."},"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"merge_key":"file_name","attributes":{"file_name":"aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}},"responses":{"200":{"description":"An existing image matched and was updated (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}}},"201":{"description":"No match — an image was created (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"file_name":"aria-hero.jpg","image_url":"https://cdn.merchkit.example/aria/aria-hero.jpg","alt_text":"Aria Oak Dining Table — front hero shot"}}}}}},"400":{"description":"Validation failed (unknown merge_key/attribute, missing merge value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"More than one existing match — deduplicate or update by id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/images/batch":{"post":{"operationId":"batchWriteImages","summary":"Batch create/upsert images","description":"Up to 100 items in one call. With `merge_key`, every item gets upsert semantics (create-or-update by that key); without it, pure creates. Items are processed independently and the response is PER-ITEM: **HTTP 200 does NOT mean every item succeeded** — check each `status` (`created` | `updated` | `error`) and surface `error.message`/`error.field_errors` for failures. A 5,000-SKU ERP delta push is 50 calls with `merge_key: \"sku\"`.\n\nScope: `write:images`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:images"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"Optional shared merge key — when present, items upsert instead of create."},"items":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["attributes"],"additionalProperties":false,"properties":{"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}}}}},"example":{"merge_key":"file_name","items":[{"attributes":{"file_name":"aria-hero.jpg","alt_text":"Aria hero shot"}}]}}}},"responses":{"200":{"description":"Per-item results, in input order, under a `summary` that makes \"N of M failed\" a single field read. Check every item — a 200 only means the batch was processed. Route retries on `error.retriable`, never on message text. Summaries from re-runs must not be summed (an idempotent retry reports updates for rows the first attempt created).","content":{"application/json":{"schema":{"type":"object","required":["summary","data"],"properties":{"summary":{"$ref":"#/components/schemas/BatchSummary"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BatchItemResult"}}}},"example":{"summary":{"total":1,"created":0,"updated":1,"failed":0,"has_errors":false},"data":[{"index":0,"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","status":"updated"}]}}}},"400":{"description":"Malformed batch (empty items, >100 items, bad shape).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/vendors":{"get":{"operationId":"listVendors","summary":"List vendors","description":"Returns a paginated page of vendors in the one-map resource shape (system fields top-level, every customer key under `attributes`). Reference attributes embed COMPLETE as labeled stubs — no expansion calls; inverse-reference keys never appear (query the other side via filters, or page `/products/{id}/references`). Filter with `filter[<key>]=<value>` / `filter[<key>][<op>]=<value>` (see the `filter` parameter for the grammar), sort with `sort`, page with `limit`/`offset`, and narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:vendors`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:vendors"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."},{"name":"sort","in":"query","schema":{"type":"string"},"description":"Comma-separated keys. Prefix with `-` for descending:\n`sort=-updated_at,sku`. Defaults to `created_at` ascending.\n\nSortable: text, number, and date attributes, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference and\nlist-reference keys have no scalar order. Sorting on one returns\n`400` (`not_sortable`)."},{"name":"filter","in":"query","style":"deepObject","explode":true,"schema":{"type":"object","additionalProperties":{"type":"string"}},"description":"Narrow results by attribute value. Filtering runs in the database,\nso `pagination.total` reflects matches, not the whole catalog.\n\nTwo forms: `filter[sku]=ARIA-DT-72` is shorthand for equals.\n`filter[price][gte]=100` uses the bracket-operator form\n`filter[<key>][<op>]=<value>`.\n\nKeys: any attribute key from `GET /attributes`, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference keys like\n`vendor` match on UUID, not name.\n\nOperators:\n\n- Text: `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`\n- Number, date, timestamp: `gt`, `gte`, `lt`, `lte`. A bare date means UTC midnight.\n- Any type: `exists` (`=true` has a value, `=false` is blank)\n- Text, `id`, and reference keys: `in` (comma-separated OR-of-equals, up to 200 values)\n\nExamples:\n\n- `filter[sku]=ARIA-DT-72` one product by your own identifier\n- `filter[description][exists]=false` everything missing a description\n- `filter[price][gte]=100&filter[material]=Oak` both must match (see `filter_join`)\n- `filter[id][in]=<uuid1>,<uuid2>` refetch a known set in one call\n- `filter[vendor]=<uuid>&sort=-updated_at` one vendor's catalog, newest first\n\nA bad filter never silently matches everything. Unknown keys, unknown operators,\nor type mismatches return `400` with `field_errors` naming the exact clause.\n\nBrackets and spaces need URL-encoding. With `curl`, use\n`-G --data-urlencode \"filter[vendor_name]=Nordic Timber Co.\"`.\nIn JavaScript, let `URLSearchParams` handle it."},{"name":"filter_join","in":"query","schema":{"type":"string","enum":["and","or"],"default":"and"},"description":"How multiple `filter` clauses combine. `and` means every clause\nmust match (the default). `or` means any clause can match.\nCase-insensitive. Any other value returns `400`.\n\nOnly matters with two or more clauses. One join applies to all of\nthem. There is no grouping or nesting, so mixed AND/OR logic\nbelongs on your side.\n\nFor OR across values of the same key, prefer\n`filter[<key>][in]=a,b,c`. One clause, up to 200 values, and it\nreads better in a log."},{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"x-codeSamples":[{"lang":"bash","label":"Find one vendor by vendor_name","source":"curl -G \"https://www.merchkit.com/api/v1/vendors\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"filter[vendor_name]=Nordic Timber Co.\"\n\n# -G with --data-urlencode is the reliable shape: curl encodes the\n# brackets and any spaces in the value, so the query arrives intact."},{"lang":"bash","label":"Read a full page, only the keys you need","source":"curl -G \"https://www.merchkit.com/api/v1/vendors\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"fields=vendor_name,country\" \\\n  --data-urlencode \"limit=200\"\n\n# `fields` trims the attributes map; 200 is the maximum page size."},{"lang":"javascript","label":"Page through everything changed since a date","source":"// URLSearchParams handles the bracket encoding for you.\nconst params = new URLSearchParams({\n  \"filter[updated_at][gte]\": \"2026-08-01\",\n  fields: \"vendor_name,country\",\n  limit: \"200\",\n});\n\nconst vendors = [];\nlet offset = 0;\n\nwhile (true) {\n  params.set(\"offset\", String(offset));\n\n  const res = await fetch(`https://www.merchkit.com/api/v1/vendors?${params}`, {\n    headers: { Authorization: `Bearer ${process.env.MERCHKIT_API_KEY}` },\n  });\n\n  const body = await res.json();\n  if (!res.ok) throw new Error(body.error.message);\n\n  vendors.push(...body.data);\n\n  // Trust has_more rather than comparing counts yourself.\n  if (!body.pagination.has_more) break;\n  offset += body.pagination.limit;\n}"}],"responses":{"200":{"description":"A page of vendors.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EntityResource"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co.","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"vendor_name":"Nordic Timber Co.","country":"Denmark"}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}},"400":{"description":"Invalid filter, sort, or paging parameter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createVendor","summary":"Create a vendor","description":"Creates a vendor and returns the full resource — no follow-up GET needed. `attributes` uses the same one-map write shape as PATCH (references inline as UUIDs or `{id}` stubs). Unknown attribute keys are rejected with 400.\n\nScope: `write:vendors`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:vendors"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"parent_id":{"type":"string","format":"uuid","description":"Optional parent vendor (products: creates a variant)."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"vendor_name":"Nordic Timber Co.","website":"https://nordictimber.example","country":"Denmark"}}}}},"responses":{"201":{"description":"The created vendor (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co.","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"vendor_name":"Nordic Timber Co.","website":"https://nordictimber.example","country":"Denmark","lead_time_days":21}}}}}},"400":{"description":"Validation failed (unknown attribute key, bad value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/vendors/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getVendor","summary":"Get a vendor","description":"Full detail, same shape as the list rows. `label` is the workspace's primary-attribute value (render `label ?? id`); `attributes` is sparse — a missing key means unset or not-applicable (read `attributes[key] ?? null`). Narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:vendors`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:vendors"],"parameters":[{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"responses":{"200":{"description":"The vendor.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co.","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"vendor_name":"Nordic Timber Co.","website":"https://nordictimber.example","country":"Denmark","lead_time_days":21}}}}}},"404":{"description":"Not found — ids outside your workspace are indistinguishable from missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateVendor","summary":"Update a vendor","description":"THE one write path: set attribute values and/or move the parent in a single call (provide `parent_id`, `attributes`, or both). Writes are symmetric with reads — a GET response's `attributes` re-sent verbatim is valid (`{id, type, label}` stubs are accepted; extra stub fields ignored). `null` or `\"\"` clears a value (the key then disappears from reads); list-reference writes REPLACE the current set. Returns the full updated resource.\n\nScope: `write:vendors`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:vendors"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"parent_id":{"type":["string","null"],"format":"uuid","description":"New parent id, or null to detach."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"lead_time_days":14}}}}},"responses":{"200":{"description":"The updated vendor (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co.","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"vendor_name":"Nordic Timber Co.","website":"https://nordictimber.example","country":"Denmark","lead_time_days":21}}}}}},"400":{"description":"Validation failed (unknown key, bad value, self-parenting).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/vendors/upsert":{"post":{"operationId":"upsertVendor","summary":"Upsert a vendor by your own key","description":"Create-or-update by a customer-named merge key: `merge_key` names any defined text-family attribute (usually `sku`) and `attributes[merge_key]` supplies the value to match — trimmed, case-insensitive. No match → create (201); one match → update (200); more than one match → 409 listing the matching ids. Idempotent for ERP-style pushes: address entities by your key, no UUID mapping table needed.\n\nScope: `write:vendors`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:vendors"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["merge_key","attributes"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"A defined text-family attribute key to match on (e.g. `sku`)."},"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"merge_key":"vendor_name","attributes":{"vendor_name":"Nordic Timber Co.","country":"Denmark"}}}}},"responses":{"200":{"description":"An existing vendor matched and was updated (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co.","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"vendor_name":"Nordic Timber Co.","website":"https://nordictimber.example","country":"Denmark","lead_time_days":21}}}}}},"201":{"description":"No match — a vendor was created (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","type":"vendor","label":"Nordic Timber Co.","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"vendor_name":"Nordic Timber Co.","website":"https://nordictimber.example","country":"Denmark","lead_time_days":21}}}}}},"400":{"description":"Validation failed (unknown merge_key/attribute, missing merge value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"More than one existing match — deduplicate or update by id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/vendors/batch":{"post":{"operationId":"batchWriteVendors","summary":"Batch create/upsert vendors","description":"Up to 100 items in one call. With `merge_key`, every item gets upsert semantics (create-or-update by that key); without it, pure creates. Items are processed independently and the response is PER-ITEM: **HTTP 200 does NOT mean every item succeeded** — check each `status` (`created` | `updated` | `error`) and surface `error.message`/`error.field_errors` for failures. A 5,000-SKU ERP delta push is 50 calls with `merge_key: \"sku\"`.\n\nScope: `write:vendors`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:vendors"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"Optional shared merge key — when present, items upsert instead of create."},"items":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["attributes"],"additionalProperties":false,"properties":{"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}}}}},"example":{"merge_key":"vendor_name","items":[{"attributes":{"vendor_name":"Nordic Timber Co.","country":"Denmark"}}]}}}},"responses":{"200":{"description":"Per-item results, in input order, under a `summary` that makes \"N of M failed\" a single field read. Check every item — a 200 only means the batch was processed. Route retries on `error.retriable`, never on message text. Summaries from re-runs must not be summed (an idempotent retry reports updates for rows the first attempt created).","content":{"application/json":{"schema":{"type":"object","required":["summary","data"],"properties":{"summary":{"$ref":"#/components/schemas/BatchSummary"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BatchItemResult"}}}},"example":{"summary":{"total":1,"created":0,"updated":1,"failed":0,"has_errors":false},"data":[{"index":0,"id":"77e1b2aa-6b3d-4f19-9d10-8c2a5e7f4b21","status":"updated"}]}}}},"400":{"description":"Malformed batch (empty items, >100 items, bad shape).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/categories":{"get":{"operationId":"listCategories","summary":"List categories","description":"Returns a paginated page of categories in the one-map resource shape (system fields top-level, every customer key under `attributes`). Reference attributes embed COMPLETE as labeled stubs — no expansion calls; inverse-reference keys never appear (query the other side via filters, or page `/products/{id}/references`). Filter with `filter[<key>]=<value>` / `filter[<key>][<op>]=<value>` (see the `filter` parameter for the grammar), sort with `sort`, page with `limit`/`offset`, and narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:categories`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:categories"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."},{"name":"sort","in":"query","schema":{"type":"string"},"description":"Comma-separated keys. Prefix with `-` for descending:\n`sort=-updated_at,sku`. Defaults to `created_at` ascending.\n\nSortable: text, number, and date attributes, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference and\nlist-reference keys have no scalar order. Sorting on one returns\n`400` (`not_sortable`)."},{"name":"filter","in":"query","style":"deepObject","explode":true,"schema":{"type":"object","additionalProperties":{"type":"string"}},"description":"Narrow results by attribute value. Filtering runs in the database,\nso `pagination.total` reflects matches, not the whole catalog.\n\nTwo forms: `filter[sku]=ARIA-DT-72` is shorthand for equals.\n`filter[price][gte]=100` uses the bracket-operator form\n`filter[<key>][<op>]=<value>`.\n\nKeys: any attribute key from `GET /attributes`, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference keys like\n`vendor` match on UUID, not name.\n\nOperators:\n\n- Text: `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`\n- Number, date, timestamp: `gt`, `gte`, `lt`, `lte`. A bare date means UTC midnight.\n- Any type: `exists` (`=true` has a value, `=false` is blank)\n- Text, `id`, and reference keys: `in` (comma-separated OR-of-equals, up to 200 values)\n\nExamples:\n\n- `filter[sku]=ARIA-DT-72` one product by your own identifier\n- `filter[description][exists]=false` everything missing a description\n- `filter[price][gte]=100&filter[material]=Oak` both must match (see `filter_join`)\n- `filter[id][in]=<uuid1>,<uuid2>` refetch a known set in one call\n- `filter[vendor]=<uuid>&sort=-updated_at` one vendor's catalog, newest first\n\nA bad filter never silently matches everything. Unknown keys, unknown operators,\nor type mismatches return `400` with `field_errors` naming the exact clause.\n\nBrackets and spaces need URL-encoding. With `curl`, use\n`-G --data-urlencode \"filter[vendor_name]=Nordic Timber Co.\"`.\nIn JavaScript, let `URLSearchParams` handle it."},{"name":"filter_join","in":"query","schema":{"type":"string","enum":["and","or"],"default":"and"},"description":"How multiple `filter` clauses combine. `and` means every clause\nmust match (the default). `or` means any clause can match.\nCase-insensitive. Any other value returns `400`.\n\nOnly matters with two or more clauses. One join applies to all of\nthem. There is no grouping or nesting, so mixed AND/OR logic\nbelongs on your side.\n\nFor OR across values of the same key, prefer\n`filter[<key>][in]=a,b,c`. One clause, up to 200 values, and it\nreads better in a log."},{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"x-codeSamples":[{"lang":"bash","label":"Find one category by category_name","source":"curl -G \"https://www.merchkit.com/api/v1/categories\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"filter[category_name]=Dining Tables\"\n\n# -G with --data-urlencode is the reliable shape: curl encodes the\n# brackets and any spaces in the value, so the query arrives intact."},{"lang":"bash","label":"Read a full page, only the keys you need","source":"curl -G \"https://www.merchkit.com/api/v1/categories\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"fields=category_name,department\" \\\n  --data-urlencode \"limit=200\"\n\n# `fields` trims the attributes map; 200 is the maximum page size."},{"lang":"javascript","label":"Page through everything changed since a date","source":"// URLSearchParams handles the bracket encoding for you.\nconst params = new URLSearchParams({\n  \"filter[updated_at][gte]\": \"2026-08-01\",\n  fields: \"category_name,department\",\n  limit: \"200\",\n});\n\nconst categories = [];\nlet offset = 0;\n\nwhile (true) {\n  params.set(\"offset\", String(offset));\n\n  const res = await fetch(`https://www.merchkit.com/api/v1/categories?${params}`, {\n    headers: { Authorization: `Bearer ${process.env.MERCHKIT_API_KEY}` },\n  });\n\n  const body = await res.json();\n  if (!res.ok) throw new Error(body.error.message);\n\n  categories.push(...body.data);\n\n  // Trust has_more rather than comparing counts yourself.\n  if (!body.pagination.has_more) break;\n  offset += body.pagination.limit;\n}"}],"responses":{"200":{"description":"A page of categories.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EntityResource"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"category_name":"Dining Tables","department":"Furniture"}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}},"400":{"description":"Invalid filter, sort, or paging parameter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createCategory","summary":"Create a category","description":"Creates a category and returns the full resource — no follow-up GET needed. `attributes` uses the same one-map write shape as PATCH (references inline as UUIDs or `{id}` stubs). Unknown attribute keys are rejected with 400.\n\nScope: `write:categories`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:categories"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"parent_id":{"type":"string","format":"uuid","description":"Optional parent category (products: creates a variant)."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}},"responses":{"201":{"description":"The created category (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}}},"400":{"description":"Validation failed (unknown attribute key, bad value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/categories/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getCategory","summary":"Get a category","description":"Full detail, same shape as the list rows. `label` is the workspace's primary-attribute value (render `label ?? id`); `attributes` is sparse — a missing key means unset or not-applicable (read `attributes[key] ?? null`). Narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:categories`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:categories"],"parameters":[{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"responses":{"200":{"description":"The category.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}}},"404":{"description":"Not found — ids outside your workspace are indistinguishable from missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateCategory","summary":"Update a category","description":"THE one write path: set attribute values and/or move the parent in a single call (provide `parent_id`, `attributes`, or both). Writes are symmetric with reads — a GET response's `attributes` re-sent verbatim is valid (`{id, type, label}` stubs are accepted; extra stub fields ignored). `null` or `\"\"` clears a value (the key then disappears from reads); list-reference writes REPLACE the current set. Returns the full updated resource.\n\nScope: `write:categories`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:categories"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"parent_id":{"type":["string","null"],"format":"uuid","description":"New parent id, or null to detach."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"department":"Dining"}}}}},"responses":{"200":{"description":"The updated category (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}}},"400":{"description":"Validation failed (unknown key, bad value, self-parenting).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/categories/upsert":{"post":{"operationId":"upsertCategory","summary":"Upsert a category by your own key","description":"Create-or-update by a customer-named merge key: `merge_key` names any defined text-family attribute (usually `sku`) and `attributes[merge_key]` supplies the value to match — trimmed, case-insensitive. No match → create (201); one match → update (200); more than one match → 409 listing the matching ids. Idempotent for ERP-style pushes: address entities by your key, no UUID mapping table needed.\n\nScope: `write:categories`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:categories"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["merge_key","attributes"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"A defined text-family attribute key to match on (e.g. `sku`)."},"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"merge_key":"category_name","attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}},"responses":{"200":{"description":"An existing category matched and was updated (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}}},"201":{"description":"No match — a category was created (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","type":"category","label":"Dining Tables","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"category_name":"Dining Tables","department":"Furniture"}}}}}},"400":{"description":"Validation failed (unknown merge_key/attribute, missing merge value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"More than one existing match — deduplicate or update by id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/categories/batch":{"post":{"operationId":"batchWriteCategories","summary":"Batch create/upsert categories","description":"Up to 100 items in one call. With `merge_key`, every item gets upsert semantics (create-or-update by that key); without it, pure creates. Items are processed independently and the response is PER-ITEM: **HTTP 200 does NOT mean every item succeeded** — check each `status` (`created` | `updated` | `error`) and surface `error.message`/`error.field_errors` for failures. A 5,000-SKU ERP delta push is 50 calls with `merge_key: \"sku\"`.\n\nScope: `write:categories`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:categories"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"additionalProperties":false,"properties":{"merge_key":{"type":"string","description":"Optional shared merge key — when present, items upsert instead of create."},"items":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["attributes"],"additionalProperties":false,"properties":{"parent_id":{"type":["string","null"],"format":"uuid"},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}}}}},"example":{"merge_key":"category_name","items":[{"attributes":{"category_name":"Dining Tables"}}]}}}},"responses":{"200":{"description":"Per-item results, in input order, under a `summary` that makes \"N of M failed\" a single field read. Check every item — a 200 only means the batch was processed. Route retries on `error.retriable`, never on message text. Summaries from re-runs must not be summed (an idempotent retry reports updates for rows the first attempt created).","content":{"application/json":{"schema":{"type":"object","required":["summary","data"],"properties":{"summary":{"$ref":"#/components/schemas/BatchSummary"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BatchItemResult"}}}},"example":{"summary":{"total":1,"created":0,"updated":1,"failed":0,"has_errors":false},"data":[{"index":0,"id":"c41d09f3-2e85-47a6-b93c-d5f8e1a20c67","status":"updated"}]}}}},"400":{"description":"Malformed batch (empty items, >100 items, bad shape).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/sources":{"get":{"operationId":"listSources","summary":"List sources","description":"Returns a paginated page of sources in the one-map resource shape (system fields top-level, every customer key under `attributes`). Reference attributes embed COMPLETE as labeled stubs — no expansion calls; inverse-reference keys never appear (query the other side via filters, or page `/products/{id}/references`). Filter with `filter[<key>]=<value>` / `filter[<key>][<op>]=<value>` (see the `filter` parameter for the grammar), sort with `sort`, page with `limit`/`offset`, and narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:sources`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:sources"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."},{"name":"sort","in":"query","schema":{"type":"string"},"description":"Comma-separated keys. Prefix with `-` for descending:\n`sort=-updated_at,sku`. Defaults to `created_at` ascending.\n\nSortable: text, number, and date attributes, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference and\nlist-reference keys have no scalar order. Sorting on one returns\n`400` (`not_sortable`)."},{"name":"filter","in":"query","style":"deepObject","explode":true,"schema":{"type":"object","additionalProperties":{"type":"string"}},"description":"Narrow results by attribute value. Filtering runs in the database,\nso `pagination.total` reflects matches, not the whole catalog.\n\nTwo forms: `filter[sku]=ARIA-DT-72` is shorthand for equals.\n`filter[price][gte]=100` uses the bracket-operator form\n`filter[<key>][<op>]=<value>`.\n\nKeys: any attribute key from `GET /attributes`, plus `id`,\n`parent_id`, `created_at`, and `updated_at`. Reference keys like\n`vendor` match on UUID, not name.\n\nOperators:\n\n- Text: `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`\n- Number, date, timestamp: `gt`, `gte`, `lt`, `lte`. A bare date means UTC midnight.\n- Any type: `exists` (`=true` has a value, `=false` is blank)\n- Text, `id`, and reference keys: `in` (comma-separated OR-of-equals, up to 200 values)\n\nExamples:\n\n- `filter[sku]=ARIA-DT-72` one product by your own identifier\n- `filter[description][exists]=false` everything missing a description\n- `filter[price][gte]=100&filter[material]=Oak` both must match (see `filter_join`)\n- `filter[id][in]=<uuid1>,<uuid2>` refetch a known set in one call\n- `filter[vendor]=<uuid>&sort=-updated_at` one vendor's catalog, newest first\n\nA bad filter never silently matches everything. Unknown keys, unknown operators,\nor type mismatches return `400` with `field_errors` naming the exact clause.\n\nBrackets and spaces need URL-encoding. With `curl`, use\n`-G --data-urlencode \"filter[vendor_name]=Nordic Timber Co.\"`.\nIn JavaScript, let `URLSearchParams` handle it."},{"name":"filter_join","in":"query","schema":{"type":"string","enum":["and","or"],"default":"and"},"description":"How multiple `filter` clauses combine. `and` means every clause\nmust match (the default). `or` means any clause can match.\nCase-insensitive. Any other value returns `400`.\n\nOnly matters with two or more clauses. One join applies to all of\nthem. There is no grouping or nesting, so mixed AND/OR logic\nbelongs on your side.\n\nFor OR across values of the same key, prefer\n`filter[<key>][in]=a,b,c`. One clause, up to 200 values, and it\nreads better in a log."},{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"x-codeSamples":[{"lang":"bash","label":"Find one source by url","source":"curl -G \"https://www.merchkit.com/api/v1/sources\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"filter[url]=https://nordictimber.example/catalog/aria-oak-dining-table\"\n\n# -G with --data-urlencode is the reliable shape: curl encodes the\n# brackets and any spaces in the value, so the query arrives intact."},{"lang":"bash","label":"Read a full page, only the keys you need","source":"curl -G \"https://www.merchkit.com/api/v1/sources\" \\\n  -H \"Authorization: Bearer $MERCHKIT_API_KEY\" \\\n  --data-urlencode \"fields=url\" \\\n  --data-urlencode \"limit=200\"\n\n# `fields` trims the attributes map; 200 is the maximum page size."},{"lang":"javascript","label":"Page through everything changed since a date","source":"// URLSearchParams handles the bracket encoding for you.\nconst params = new URLSearchParams({\n  \"filter[updated_at][gte]\": \"2026-08-01\",\n  fields: \"url\",\n  limit: \"200\",\n});\n\nconst sources = [];\nlet offset = 0;\n\nwhile (true) {\n  params.set(\"offset\", String(offset));\n\n  const res = await fetch(`https://www.merchkit.com/api/v1/sources?${params}`, {\n    headers: { Authorization: `Bearer ${process.env.MERCHKIT_API_KEY}` },\n  });\n\n  const body = await res.json();\n  if (!res.ok) throw new Error(body.error.message);\n\n  sources.push(...body.data);\n\n  // Trust has_more rather than comparing counts yourself.\n  if (!body.pagination.has_more) break;\n  offset += body.pagination.limit;\n}"}],"responses":{"200":{"description":"A page of sources.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EntityResource"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"5cba3f0d-9e14-4b6a-a2d7-31f8c5e9b042","type":"source","label":"https://nordictimber.example/catalog/aria-oak-dining-table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"url":"https://nordictimber.example/catalog/aria-oak-dining-table"}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}},"400":{"description":"Invalid filter, sort, or paging parameter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createSource","summary":"Create a source (async)","description":"Schedules scrape-and-create: the processing pipeline fetches the URL and creates the source entity WITH its `processed_content` — there is no \"create empty, fill later\" path. Returns `202 {job_id, status: \"queued\"}`; poll `GET /jobs/{job_id}` until `completed`, then find the source via `GET /sources?filter[url]=<url>`. Attaching to a product is a `data_sources` reference write on the product (`PATCH /products/{id}`).\n\nScope: `write:sources`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:sources"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["attributes"],"additionalProperties":false,"properties":{"attributes":{"type":"object","required":["url"],"additionalProperties":false,"properties":{"url":{"type":"string","format":"uri","description":"The URL to scrape and process."}}}}},"example":{"attributes":{"url":"https://nordictimber.example/catalog/aria-oak-dining-table"}}}}},"responses":{"202":{"description":"Processing scheduled; poll the job.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["job_id","status"],"properties":{"job_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["queued"]}}}}},"example":{"data":{"job_id":"1f7c9a2b-6d43-4e8f-b501-a92c7d3e6f18","status":"queued"}}}}},"400":{"description":"Validation failed (missing or invalid `url`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/sources/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getSource","summary":"Get a source","description":"Full detail, same shape as the list rows. `label` is the workspace's primary-attribute value (render `label ?? id`); `attributes` is sparse — a missing key means unset or not-applicable (read `attributes[key] ?? null`). Narrow the returned keys with `fields=<key>,<key>`.\n\nScope: `read:sources`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:sources"],"parameters":[{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"responses":{"200":{"description":"The source.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"5cba3f0d-9e14-4b6a-a2d7-31f8c5e9b042","type":"source","label":"https://nordictimber.example/catalog/aria-oak-dining-table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"url":"https://nordictimber.example/catalog/aria-oak-dining-table","type":"URL","processed_content":"Aria Oak Dining Table — solid white oak, 72\" × 38\" top, seats six. Danish design, oiled finish, ships flat-packed…"}}}}}},"404":{"description":"Not found — ids outside your workspace are indistinguishable from missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateSource","summary":"Update a source","description":"THE one write path: set attribute values and/or move the parent in a single call (provide `parent_id`, `attributes`, or both). Writes are symmetric with reads — a GET response's `attributes` re-sent verbatim is valid (`{id, type, label}` stubs are accepted; extra stub fields ignored). `null` or `\"\"` clears a value (the key then disappears from reads); list-reference writes REPLACE the current set. Returns the full updated resource.\n\nScope: `write:sources`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:sources"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"parent_id":{"type":["string","null"],"format":"uuid","description":"New parent id, or null to detach."},"attributes":{"$ref":"#/components/schemas/AttributeWriteMap"}}},"example":{"attributes":{"url":"https://nordictimber.example/catalog/aria-oak-dining-table"}}}}},"responses":{"200":{"description":"The updated source (full resource).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntityResource"}}},"example":{"data":{"id":"5cba3f0d-9e14-4b6a-a2d7-31f8c5e9b042","type":"source","label":"https://nordictimber.example/catalog/aria-oak-dining-table","parent_id":null,"created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"url":"https://nordictimber.example/catalog/aria-oak-dining-table","type":"URL","processed_content":"Aria Oak Dining Table — solid white oak, 72\" × 38\" top, seats six. Danish design, oiled finish, ships flat-packed…"}}}}}},"400":{"description":"Validation failed (unknown key, bad value, self-parenting).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{id}/variants":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"listProductVariants","summary":"List a product's variants","description":"The products whose `parent_id` is this product, as full resources.\n\nScope: `read:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:products"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."},{"name":"fields","in":"query","schema":{"type":"string"},"description":"Return only the attribute keys you name instead of every populated\none. Useful when a workspace has hundreds of attributes and your\nintegration reads a handful.\n\n`fields=sku,price` (comma-separated) or `fields=sku&fields=price`\n(repeated param, same result). Omit it entirely to get every\npopulated key.\n\nOnly the `attributes` map narrows. System fields (`id`, `type`,\n`label`, `parent_id`, `created_at`, `updated_at`) always come back,\nso naming one returns `400`. `label` still resolves from the primary\nattribute whether or not you select it. `pagination.total` is\nunchanged. `filter` and `sort` are independent: filter on keys you\ndid not select.\n\nSparse still means sparse. A selected key with no value stays\nabsent. Keep reading `attributes[key] ?? null`.\n\nReference keys return `{id, type, label}` stubs as usual, but\n`fields` does not reach through them.\n`fields=gallery_images.image_url` is not supported. Select the\nreference key, then batch-fetch:\n`GET /images?filter[id][in]=<ids>&fields=image_url`.\n\nUnknown keys return `400` with `field_errors`. Inverse-reference\nkeys are rejected the same way. They are queries, not stored values.\nRead them via `/{resource}/{id}/references?attribute=`.\n\nSelecting only non-reference keys skips the reference join entirely,\nwhich avoids loading every referenced entity just to attach its label."}],"responses":{"200":{"description":"A page of variant products.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EntityResource"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"3c77a1de-52b9-4f0c-8a4e-91d2c6b7e803","type":"product","label":"Aria Oak Dining Table 96\"","parent_id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","created_at":"2026-06-02T14:11:09Z","updated_at":"2026-07-18T09:30:22Z","attributes":{"sku":"ARIA-DT-96","product_name":"Aria Oak Dining Table 96\"","price":1699,"material":"White Oak"}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}},"404":{"description":"Product not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{id}/references":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"listProductReferences","summary":"List a product's references, with positions","description":"The key-scoped reference view: `{ <attribute-key>: { total, has_more, items: [{id, type, label, position}] } }`. Forward list references return the same complete membership that embeds in `attributes`, plus explicit `position`. INVERSE references — which never embed in `attributes` because they are unbounded — are readable ONLY here: pass `?attribute=<key>` and page with `limit`/`offset` (or traverse from the other side with `filter[<forward-key>]=<id>`).\n\nScope: `read:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:products"],"parameters":[{"name":"attribute","in":"query","schema":{"type":"string"},"description":"A reference attribute key. Required to read an inverse reference; omit for all forward reference keys."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."}],"responses":{"200":{"description":"References grouped by attribute key.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/ReferencesPage"}}}},"example":{"data":{"gallery_images":{"total":5,"has_more":false,"items":[{"id":"f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b","type":"image","label":"aria-hero.jpg","position":0},{"id":"f0a2c3d4-e5f6-4a70-9b8c-0d1e2f3a4b5c","type":"image","label":"aria-detail.jpg","position":1},{"id":"f0a3d4e5-f6a7-4b80-8c9d-1e2f3a4b5c6d","type":"image","label":"aria-side.jpg","position":2},{"id":"f0a4e5f6-a7b8-4c90-9dae-2f3a4b5c6d7e","type":"image","label":"aria-lifestyle.jpg","position":3},{"id":"f0a5f6a7-b8c9-4da0-8ebf-3a4b5c6d7e8f","type":"image","label":"aria-packshot.jpg","position":4}]}}}}}},"404":{"description":"Product or reference attribute not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{id}/completeness":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getProductCompleteness","summary":"Check completeness for a channel","description":"Recomputes the product's completeness for one channel on demand. `channel` is REQUIRED — completeness is per-channel. Requirements come from the workspace's attribute rules. Issues carry `acceptable_values` where applicable: exactly what the write path validates against, so the fix loop closes without a human.\n\nScope: `read:products`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:products"],"parameters":[{"name":"channel","in":"query","required":true,"schema":{"type":"string"},"description":"A channel id from `GET /channels`, or `core` for the core attributes (those without a channel)."}],"responses":{"200":{"description":"Completeness status.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Completeness"}}},"example":{"data":{"product_id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","channel":"wayfair","is_complete":false,"no_requirements":false,"missing_count":1,"required_missing_count":1,"recommended_missing_count":0,"invalid_count":1,"issues":[{"key":"description","name":"Description","status":"missing","current_value":null,"requirement":"required"},{"key":"assembly_required","name":"Assembly Required","status":"invalid","current_value":"maybe","requirement":"required","acceptable_values":["Yes","No"]}]}}}}},"404":{"description":"Product or channel not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{id}/events":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"listProductEvents","summary":"List a product's change events","description":"Per-entity change events from the append-only log — identical shape and envelope to the workspace feed (`GET /events`), scoped to one product. Newest-first; same `next_since` polling recipe.\n\nScope: `read:events`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:events"],"parameters":[{"name":"since","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only events created at/after this ISO-8601 timestamp."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Change events for this product, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"pagination":{"$ref":"#/components/schemas/EventPagination"}}},"example":{"data":[{"id":"e7c94b1f-2a68-4d05-9c3e-8b1f6a2d7c50","type":"entity.updated","entity_id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","entity_type":"product","attribute_key":"price","value":1299,"actor":{"kind":"api_key"},"created_at":"2026-07-18T09:30:22Z"}],"pagination":{"limit":50,"has_more":false,"next_since":"2026-07-18T09:30:22Z"}}}}},"404":{"description":"Product not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/attributes":{"get":{"operationId":"listAttributes","summary":"List attribute definitions (schema discovery)","description":"The schema of an entity type: every attribute key, its data type, whether it is `writable` (formulas/inverse references are not), and the parsed `acceptable_values` for selects. Call this FIRST and cache it — entity `attributes` maps are sparse, so this is the canonical \"what fields exist\" answer. Optionally scope to a channel.\n\nScope: `read:attributes`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:attributes"],"parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["product","image","vendor","category","source"],"default":"product"},"description":"The entity type to describe."},{"name":"channel","in":"query","schema":{"type":"string"},"description":"Restrict to one channel's attributes."}],"responses":{"200":{"description":"Attribute definitions (not paginated).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AttributeDefinition"}}}},"example":{"data":[{"id":"a3d81c22-5f6e-4b09-8d17-c40a92e5b613","key":"material","name":"Material","entity_type":"product","data_type":"single_select_list","channel":null,"is_primary":false,"writable":true,"acceptable_values":["White Oak","Smoked Oak","Walnut"],"default_value":null,"formula_expression":null}]}}}},"400":{"description":"Invalid `type`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createAttribute","summary":"Create an attribute definition","description":"Create an attribute, including full AI-generation config (snake_case body). For an AI-generated attribute set `ai_generated: true` + `custom_instructions` (reference other attributes with `{{key}}`), and pick `selected_tools`: `includeWebSearch` (live web research), `includeDataSources` (scraped sites/CSVs), `includeImages` (vision), `includeProductAttributes`. Image/video generation models are supported. `inheritance_mode` shares a value across a variant family (products only).\n\nScope: `write:attributes`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:attributes"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeDefinitionCreate"},"example":{"name":"Material","entity_type":"product","data_type":"single_select_list","ai_generated":false,"acceptable_values":"White Oak,Smoked Oak,Walnut"}}}},"responses":{"201":{"description":"The created attribute definition (full config).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributeDefinitionDetail"}}},"example":{"data":{"id":"a3d81c22-5f6e-4b09-8d17-c40a92e5b613","name":"Material","key":"material","data_type":"single_select_list","tool":"manual","entity_type":"product","channel":null,"is_primary":false,"custom_instructions":null,"acceptable_values":"White Oak,Smoked Oak,Walnut","default_value":null,"constraints":{},"source_attributes":null}}}}},"400":{"description":"Validation failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/attributes/{key}":{"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"},"description":"The attribute key."}],"get":{"operationId":"getAttribute","summary":"Get one attribute definition","description":"One attribute definition by `key`. `type` selects the entity type (default `product`) — keys are namespaced per type, so the wrong `type` returns 404. Same projection as `GET /attributes`: the `data_type`, whether the key is `writable`, and its parsed `acceptable_values`. Use this to re-check a single key; use `GET /attributes` to learn the whole schema.\n\nScope: `read:attributes`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:attributes"],"parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["product","image","vendor","category","source"],"default":"product"},"description":"The entity type the key belongs to."}],"responses":{"200":{"description":"The attribute definition.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributeDefinition"}}},"example":{"data":{"id":"a3d81c22-5f6e-4b09-8d17-c40a92e5b613","key":"material","name":"Material","entity_type":"product","data_type":"single_select_list","channel":null,"is_primary":false,"writable":true,"acceptable_values":["White Oak","Smoked Oak","Walnut"],"default_value":null,"formula_expression":null}}}}},"404":{"description":"Attribute not found for that type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateAttribute","summary":"Update an attribute definition","description":"Identified by the path `key` + `entity_type` in the body. Provide only the fields to change; array fields replace wholesale.\n\nScope: `write:attributes`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["write:attributes"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeDefinitionUpdate"},"example":{"entity_type":"product","acceptable_values":"White Oak,Smoked Oak,Walnut,Ash"}}}},"responses":{"200":{"description":"The updated attribute definition (full config).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributeDefinitionDetail"}}},"example":{"data":{"id":"a3d81c22-5f6e-4b09-8d17-c40a92e5b613","name":"Material","key":"material","data_type":"single_select_list","tool":"manual","entity_type":"product","channel":null,"is_primary":false,"custom_instructions":null,"acceptable_values":"White Oak,Smoked Oak,Walnut","default_value":null,"constraints":{},"source_attributes":null}}}}},"404":{"description":"Attribute not found for that type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/channels":{"get":{"operationId":"listChannels","summary":"List enabled channels","description":"The channels enabled for this workspace. Read-only — channels are a global, platform-seeded catalog. Channel ids feed `GET /products/{id}/completeness?channel=` and `GET /attributes?channel=`.\n\nScope: `read:channels`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:channels"],"responses":{"200":{"description":"Enabled channels.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Channel"}}}},"example":{"data":[{"id":"wayfair","name":"Wayfair","description":"Wayfair marketplace listings","entityType":"product"}]}}}}}}},"/events":{"get":{"operationId":"listEvents","summary":"Workspace change feed","description":"The account-wide change feed, NEWEST-FIRST. Poll with `since`: when `has_more` is false, the page holds every event since your checkpoint — save `next_since` (the newest `created_at`), overlap the next poll by a second, dedupe on event `id`. When `has_more` is true the window overflowed one page and middle events are NOT reachable by paging — recover by state, not by log: `GET /{resource}?filter[updated_at][gte]=<checkpoint>` per synced resource. Batch-refetch changed entities with `filter[id][in]=…`.\n\nScope: `read:events`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:events"],"parameters":[{"name":"since","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only events created at/after this ISO-8601 timestamp."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Change events, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"pagination":{"$ref":"#/components/schemas/EventPagination"}}},"example":{"data":[{"id":"e7c94b1f-2a68-4d05-9c3e-8b1f6a2d7c50","type":"entity.updated","entity_id":"9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21","entity_type":"product","attribute_key":"price","value":1299,"actor":{"kind":"api_key"},"created_at":"2026-07-18T09:30:22Z"}],"pagination":{"limit":50,"has_more":false,"next_since":"2026-07-18T09:30:22Z"}}}}}}}},"/jobs":{"get":{"operationId":"listJobs","summary":"List recent jobs","description":"Asynchronous jobs for this workspace, newest first.\n\nScope: `read:jobs`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:jobs"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Rows per page, 1 to 200. Defaults to 50.\n\nLarger pages mean fewer round-trips. 200 is also the cap on `in`\noperator value lists, so refetching a full page of IDs with\n`filter[id][in]=...` always fits in one call."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip. Zero-based. Echoed back in `pagination.offset`.\n\nPage forward by adding `limit` each time. Stop when\n`pagination.has_more` is `false` rather than comparing counts.\nDefault order is `created_at` ascending, which keeps long sweeps\nstable: new rows land at the end instead of shifting pages you\nhave not read yet."}],"responses":{"200":{"description":"A page of jobs.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Job"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"example":{"data":[{"id":"1f7c9a2b-6d43-4e8f-b501-a92c7d3e6f18","name":"Process Source","status":"running","progress":{"current":40,"total":100,"percent":40},"created_at":"2026-07-18T09:30:22Z","started_at":"2026-07-18T09:30:22Z","completed_at":null,"result":null,"metadata":{"workflow":"process-source","url":"https://nordictimber.example/catalog/aria-oak-dining-table","via":"api_v1"}}],"pagination":{"total":128,"limit":50,"offset":0,"has_more":true}}}}}}}},"/jobs/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The resource UUID."}],"get":{"operationId":"getJob","summary":"Poll an async job","description":"Status + progress of an asynchronous operation (source processing, imports, workflow runs). `POST /sources` returns a `job_id` to poll here; terminal statuses are `completed`, `failed`, and `cancelled`.\n\nScope: `read:jobs`.","security":[{"ApiKeyAuth":[]}],"x-required-scopes":["read:jobs"],"responses":{"200":{"description":"Job status.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Job"}}},"example":{"data":{"id":"1f7c9a2b-6d43-4e8f-b501-a92c7d3e6f18","name":"Process Source","status":"running","progress":{"current":40,"total":100,"percent":40},"created_at":"2026-07-18T09:30:22Z","started_at":"2026-07-18T09:30:22Z","completed_at":null,"result":null,"metadata":{"workflow":"process-source","url":"https://nordictimber.example/catalog/aria-oak-dining-table","via":"api_v1"}}}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/openapi":{"get":{"operationId":"getOpenApiDocument","summary":"This OpenAPI document","description":"The machine-readable spec for this API. Public — no key required.","security":[],"responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object"},"example":{"openapi":"3.1.0","info":{"title":"Merchkit API"}}}}}}}}}}