# Merchkit API Merchkit is an AI-native PIM (Product Information Management) system. Five resources — **products, images, vendors, categories, sources** — are the same kind of object (an entity with dynamic **attributes**) and share identical list/read/write mechanics. Attributes can be channel-scoped, and **completeness is per-channel**: a product can be complete for one channel and incomplete for another. ## Authentication Send your workspace API key as a bearer token: Authorization: Bearer mk_live_... One key maps to exactly one workspace. Keys carry scopes; read keys cannot perform writes. Available scopes: - `read:products`: List and read products, including variants, references, and completeness. - `write:products`: Create, update, and upsert products and their attribute values. - `delete:products`: Delete products. - `read:images`: List and read images. - `write:images`: Create, update, and upsert images and their attribute values. - `delete:images`: Delete images. - `read:vendors`: List and read vendors. - `write:vendors`: Create, update, and upsert vendors and their attribute values. - `delete:vendors`: Delete vendors. - `read:categories`: List and read categories. - `write:categories`: Create, update, and upsert categories and their attribute values. - `delete:categories`: Delete categories. - `read:sources`: List and read data sources, including scraped content. - `write:sources`: Create data sources (triggers processing) and update their attribute values. - `delete:sources`: Delete data sources. - `read:attributes`: List and read attribute definitions. - `write:attributes`: Create and update attribute definitions. - `read:views`: List and read saved grid views (MCP surface). - `write:views`: Create and update grid views (MCP surface). - `read:channels`: Read the enabled channel catalog. - `read:events`: Read the workspace change feed and per-entity events. - `read:jobs`: List and poll asynchronous job status. ## Base URL & spec - Base URL: `/api/v1` - OpenAPI: `/api/v1/openapi` - Human docs: https://docs.merchkit.com/developers/overview ## The identity rule **The UUID is the address; your SKU is a filter.** Item paths take UUIDs only (`GET /v1/products/{id}`); reference values are UUIDs. Resolve your own key with `?filter[sku]=ARIA-DT-72` (or 100 at once via `filter[sku][in]=...`). To write by your own key without resolving IDs at all, use `POST /{resource}/upsert` or `POST /{resource}/batch` with a `merge_key` — available for **products, images, vendors, categories** only. **Sources have neither route**; create them with the async pipeline in "Push a data source" below. ## The resource shape (one map, everywhere) List rows, detail reads, and mutation responses are identical. System fields top-level; EVERY customer-defined key lives in a sparse `attributes` map: { "id": "9b2f6c1e-...", "type": "product", "label": "Aria Oak Dining Table", // primary value, or null — render label ?? id "parent_id": null, "created_at": "2026-06-02T14:11:09Z", "updated_at": "2026-07-18T09:30:22Z", "attributes": { "sku": "ARIA-DT-72", "price": 1299, "vendor": { "id": "77e1b2aa-...", "type": "vendor", "label": "Nordic Timber Co." }, "gallery_images": [ { "id": "f0a1...", "type": "image", "label": "aria-hero.jpg" }, ... ] } } - `attributes` is **sparse**: only keys with values appear. Read with `attributes[key] ?? null`. The full key list comes from `GET /v1/attributes?type=product` (call it first, cache it — it also tells you `data_type`, `writable`, `is_primary`, and `acceptable_values`). - Writing `null` or `""` **clears** a value; the key then disappears from reads. - References read as labeled stubs `{id, type, label}`; list references are complete ordered arrays. Writes are symmetric ("write what you read"): send a bare UUID, an `{id}` object, or arrays of either — re-sending a read stub verbatim is a valid write. List writes replace the whole list. - Inverse references never appear in `attributes` — query them: `GET /v1/products?filter[]=` or `GET /v1/products/{id}/references?attribute=` (paged). - Mutations return the full resource — never follow up with a GET. ## Filtering & field selection `filter[]=` is equals; `filter[][]=` otherwise. Keys: any defined attribute or builtin `id | parent_id | created_at | updated_at`. Operators: `eq ne contains not_contains starts_with ends_with gt gte lt lte exists in`. `exists=true/false` replaces blank/notBlank. `in` is comma-separated OR-of-equals (text/id/reference only, max 200 values). `filter_join=and|or`. `sort=-updated_at,sku` (references not sortable). `limit` 1–200 (default 50), `offset` ≥ 0. Envelope: `{ data, pagination: { total, limit, offset, has_more } }`. `fields=sku,gallery_images` (or repeat `fields=`) narrows the `attributes` map to those keys — on `GET /{resource}`, `GET /{resource}/{id}`, and `GET /products/{id}/variants`; omitted or empty means no narrowing. System fields always come back, so naming one 400s, and `label` keeps resolving from the primary attribute whether or not you select it. Independent of `filter`/`sort` — you may filter and sort on keys you did not request, and `pagination.total` is unchanged. Sparse still applies: a requested key with no value is still **absent**, so keep reading `attributes[key] ?? null`. Unknown and inverse-reference keys 400 with every offender in `field_errors`. References still read as complete `{id, type, label}` stubs; **there is no dotted traversal** — `fields=gallery_images.image_url` is not supported (see the two-call recipe below). When no requested key is a reference/lookup type the reference join is skipped entirely — that is the real payload win. ## Recipes (condensed) - **Find by SKU:** `GET /v1/products?filter[sku]=ARIA-DT-72` → PATCH the id. - **Full pull:** `GET /v1/attributes?type=product` once, then page `GET /v1/products?limit=200&sort=created_at` — references arrive complete, no expansion calls. - **Batch refetch (prefer this over per-id GETs):** `GET /v1/products?filter[id][in]=id1,id2,...` — up to 200 per call. - **Image URLs for a set of products (two small calls, no traversal):** `GET /v1/products?limit=200&fields=gallery_images` → collect the image ids from the returned stubs → `GET /v1/images?filter[id][in]=id1,id2,...&fields=image_url`. The `in` cap (200) equals the max page size, so one product page always resolves in exactly one follow-up call. Same shape for any reference: select the reference key, then batch-fetch that type with the keys you need. - **Create wired to refs:** create/look up vendor + images first, then one `POST /v1/products` with all references inline in `attributes`. - **ERP delta push (5,000 SKUs = 50 calls):** `POST /v1/products/batch {"merge_key":"sku","items":[...≤100]}` → `{summary: {total, created, updated, failed, has_errors}, data: [{index, merge_value, id?, status: created|updated|error, error?: {code, retriable, message, field_errors}}]}`. HTTP 200 does NOT mean all succeeded — check `summary.has_errors`, join failures to source rows by `merge_value`, and route retries on `error.retriable` (never on message text). Re-runs are idempotent; do not sum summaries across re-runs. Single record: `POST /v1/products/upsert` (201 created / 200 updated / 409 ambiguous). - **Incremental sync:** `GET /v1/events?since=&limit=200` (newest-first) → refetch via `filter[id][in]`. Checkpoint `pagination.next_since`, overlap the next poll by 1s, dedupe on event `id`. If `has_more` is true the window overflowed — recover by state: `GET /{resource}?filter[updated_at][gte]=`. Events are retained per the workspace's subscription plan — treat the feed as a sync mechanism, not an archive; for old checkpoints prefer the state catch-up over paging months of events. - **Push a data source:** `POST /v1/sources {"attributes":{"url":"..."}}` → 202 `{job_id}` → poll `GET /v1/jobs/{job_id}` until `completed` → find it via `GET /v1/sources?filter[url]=...` → attach with `PATCH /v1/products/{id} {"attributes":{"data_sources":[...ids]}}`. - **Completeness loop:** `GET /v1/products/{id}/completeness?channel=` → issues carry `key`, `status` (missing|invalid), `current_value`, `requirement`, and `acceptable_values` — PATCH the fix, re-check. ## Self-correction Errors are machine-readable: `code`, `is_retriable`, `retry_after_seconds`, `alternative_action`, and `field_errors[]` with stable `issue` codes. When a select value is rejected, `field_errors[].acceptable_values` lists exactly what would have passed — fix the payload from that list and retry; do not ask a human. Docs per code: https://docs.merchkit.com/developers/errors ## Rules of thumb - **Enrich with `PATCH /v1/products/{id}`** — never recreate a product to change a value. `{attributes: {...}}` and/or `{parent_id}` in one call. - **Completeness is channel-specific — always pass `?channel=`.** - **Find products missing a value:** `filter[][exists]=false`. - **Unknown attribute keys 400 loudly** — list keys via `GET /v1/attributes`. - **Any id outside your workspace is a uniform 404** — there is no cross-tenant 403 to distinguish. - **Channels are read-only and global**; **deletes are disabled during beta** (403). Rate limits are enforced at the edge — honor `Retry-After` on 429. ## MCP (operate from Claude and other agents) - Remote MCP endpoint (Streamable HTTP): `/api/mcp` — OAuth, or the same `Authorization: Bearer mk_live_...` key. - **MCP mirrors REST exactly**: generic tools over the same shapes — `list_entities(type, ...)`, `get_entity`, `create_entity`, `update_entity`, `upsert_entity`, `list_variants`, `get_references`, `get_completeness`, `list_events`, `list_attributes`, `get_attribute`, `create_attribute`, `update_attribute`, `list_channels`, `list_jobs`, `get_job` — plus MCP-only grid-view and attribute-class tools. `type` is a parameter exactly like the REST path segment (`product | image | vendor | category | source`). MCP filters are structured objects `{attribute_key, operator, value}` using the SAME short operator spellings as REST (`eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`, `gt`, `gte`, `lt`, `lte`, `exists`, `in`). `list_entities`, `get_entity`, and `list_variants` take the same field selection as an optional `fields: string[]`. Bulk pushes: `upsert_entities(type, merge_key, items ≤100)` — same {summary, data} per-item results as REST `POST /{resource}/batch`. - **Write tools take `confirm`**: call without it for a preview diff, then again with `confirm: true` to apply.