Skip to main content
These conventions apply across the entire /api/v1 surface. The same projection, filtering engine, and error shapes power both the REST API and the MCP server, so semantics never diverge.
Examples use https://www.merchkit.com as the base URL.

Resource anatomy

Every entity resource uses one shape everywhere. products, images, vendors, categories, and sources return identical list rows, detail reads, and mutation responses. System fields live at the top level. Every customer-defined key lives under attributes.

The label rule

label is the entity’s primary-attribute value (usually its name or SKU). When the workspace has no primary attribute configured, or the value is unset, label is null. Render a label the same way everywhere it appears, at the top level and inside reference stubs alike:

Sparse attributes

attributes contains only keys that have values. A missing key means unset or not applicable (disabled by an attribute class). Both truthfully mean “no value here”. Read an attribute with a fallback:
The full key list comes from GET /v1/attributes?type=product, including keys currently absent from every product (cache it). Unknown keys on writes and filters still fail with a 400.

The empty string is not a value

Writing null or "" clears an attribute, for every data type. After clearing, the key disappears from reads. attributes never contains null or "". Whitespace-only strings are stored as sent, so trim before writing if you need to. filter[key][exists]=false matches cleared and never-set alike.

Reference stubs and symmetric writes

A single reference reads as {id, type, label}. A list reference reads as an ordered array of those stubs. Array order is display order, with no cap and no wrapper. You always have the name inline, so fetch the full record only when you need more than the label. Writes follow “write what you read”. A single-reference key accepts a bare UUID string or an {id: "…"} object; extra stub fields like label and type are tolerated and ignored. A list-reference key accepts an array of either, with replace semantics. A GET → modify → PATCH round-trip of the whole attributes object is therefore always legal, since read stubs re-sent verbatim are valid writes.
Reference targets must already exist in your workspace. A typo’d UUID fails with referenced_entity_not_found instead of silently minting a vendor.

Inverse references are queries, not data

Forward references (a product’s vendor, its gallery_images) embed complete. Inverse references, like “every product pointing at this category”, are unbounded, so they never appear in attributes. Traverse them as queries:
GET /{id}/references returns each key as {total, has_more, items: [{id, type, label, position}]}, with explicit positions. Without ?attribute= it returns every forward list key, complete. Inverse keys require ?attribute= and page with limit/offset.

Mutations return the full resource

POST (create) and PATCH responses carry the complete resource in the shape above, with no follow-up GET needed. parent_id (variant parent) is always present, and null when there is none.

Filtering and sorting

filter[<key>]=<value> is equals; filter[<key>][<op>]=<value> for everything else. <key> is any defined attribute key or a builtin: id, parent_id, created_at, updated_at. Example one-liners:
and | or
default:"and"
How multiple filter clauses combine. Case-insensitive.
string
Comma-separated fields, - prefix for descending: sort=-updated_at,sku. Sortable: text, number, and date attributes plus the builtins. Reference keys are not sortable. Sorting one returns a 400 (not_sortable), never a silent no-op.
Unknown keys, unknown operators, and operator/type mismatches all return 400 with field_errors naming the exact clause.
filter[<key>][exists]=false answers the most common enrichment question, “which products are missing an attribute?”, and matches both never-set and cleared values.

Pagination

List endpoints return rows under data and cursor metadata under pagination.
integer
default:"50"
Page size, 1–200. Larger pages mean fewer round-trips, which agents generally prefer.
integer
default:"0"
How many rows to skip. Combine with limit to page through results.
boolean
Whether more pages exist. Read has_more rather than computing it. It is the canonical signal to stop paging.

Events: the change feed

GET /v1/events?since=&limit= is the workspace-wide change feed; GET /v1/products/{id}/events is the same shape scoped to one product. Both require read:events.
type is entity.created | entity.updated | entity.deleted. value is the new value; there is no old value, since the log stores only what was written. entity_type is null for deleted entities. actor.kind is user | api_key | system and nothing more. Events are returned newest-first.

Polling recipe

1

Poll with your checkpoint

GET /v1/events?since=<checkpoint>&limit=200. When has_more is false, the page holds every event since your checkpoint.
2

Advance the checkpoint

Set your checkpoint to pagination.next_since (the newest created_at). On the next poll, overlap by one second and dedupe on event id, so same-second events on the boundary can’t slip through.
3

Refetch what changed

Batch-refetch the touched entities in one call per resource: GET /v1/products?filter[id][in]=<ids> (a full 200-event page refetches in a single call).
4

Recover from overflow

When has_more is true, the window exceeded one page and middle events are not reachable by paging. Recover by state rather than by log: GET /{resource}?filter[updated_at][gte]=<checkpoint> per synced resource. Deletions during an overflow window surface on your next full reconcile, so poll often enough that overflow stays rare.

Retention

Events are retained according to your subscription plan. Treat the feed as a sync mechanism, not a permanent archive. If your checkpoint is older than your plan’s retention window, or simply weeks old, recover by state instead (filter[updated_at][gte]=<checkpoint> per resource), which is also faster than paging months of events one window at a time.

Async jobs

Long-running operations return 202 Accepted with a job handle instead of blocking:
Poll GET /v1/jobs/{job_id} until a terminal status (completed, failed, cancelled):
GET /v1/jobs lists recent jobs, newest first. result is reserved, and null today.

Source processing, end to end

POST /v1/sources schedules a scrape. The source entity is created by the pipeline, with its scraped processed_content:
One source may attach to many products. There is no upsert or batch for sources; creation is always the processing pipeline.

Errors

Every non-2xx response uses one machine-recoverable envelope: code, is_retriable, retry_after_seconds, alternative_action, and per-field field_errors whose acceptable_values tell you exactly what would have passed. The full catalog, code by code, lives on the Errors page.
Rate limits are enforced at the edge during the beta, and no fixed numbers are published. If you receive a 429, honor the Retry-After header.

Tenancy: 404, not 403

Any id outside your workspace is a uniform 404 not_found, indistinguishable from an id that never existed. The API never confirms that a foreign resource exists, so don’t build logic that distinguishes “no access” from “no such record”. There is no such distinction.

Reserved extensions

Documented now so you can plan for them; none are emitted yet:
  • attribute_metadata: a future top-level sibling of attributes carrying per-value metadata (confidence, flags) behind ?include=attribute_metadata. attributes values themselves will never grow envelopes.
  • Idempotency-Key: a reserved request header for safe write retries.
  • ?attributes=sku,price: a reserved sparse-fieldset parameter for narrowing rows.
  • Webhooks: the committed roadmap item for push-based sync. Until then, sync is polling: the events feed plus filter[updated_at][gte] recovery, the same reconciliation pattern the feed’s overflow recipe already uses. When webhooks ship, subscriptions will be provisioned via the API (not app-only configuration) with signed payloads.