/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:
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
Writingnull 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.
referenced_entity_not_found instead of silently minting a vendor.
Inverse references are queries, not data
Forward references (a product’svendor, 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.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 underdata 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 return202 Accepted with a job handle instead of blocking:
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:
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
Anyid 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 ofattributescarrying per-value metadata (confidence, flags) behind?include=attribute_metadata.attributesvalues 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.