> ## Documentation Index
> Fetch the complete documentation index at: https://docs.merchkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions

> Cross-cutting conventions: the one-map resource shape, labeled references, the filter grammar, the pagination envelope, events polling, async jobs, and reserved extensions.

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.

<Note>
  Examples use `https://www.merchkit.com` as the base URL.
</Note>

## 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`.

```json theme={null}
{
  "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,
    "vendor": { "id": "77e1b2aa-4f6d-4c1a-9e6b-2f8a1d3c5e70", "type": "vendor", "label": "Nordic Timber Co." },
    "gallery_images": [
      { "id": "f0a1c2d3-e4b5-46a7-98c9-0d1e2f3a4b5c", "type": "image", "label": "aria-hero.jpg" },
      { "id": "f0a2d3e4-f5a6-47b8-a9d0-1e2f3a4b5c6d", "type": "image", "label": "aria-detail.jpg" }
    ]
  }
}
```

### 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:

```ts theme={null}
const display = resource.label ?? resource.id;
```

### 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:

```ts theme={null}
const price = resource.attributes["price"] ?? null;
```

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.

```bash theme={null}
curl -X PATCH "https://www.merchkit.com/api/v1/products/9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21" \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "attributes": {
      "vendor": "77e1b2aa-4f6d-4c1a-9e6b-2f8a1d3c5e70",
      "gallery_images": [
        { "id": "f0a1c2d3-e4b5-46a7-98c9-0d1e2f3a4b5c" },
        "f0a2d3e4-f5a6-47b8-a9d0-1e2f3a4b5c6d"
      ]
    }
  }'
```

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:

```bash theme={null}
# All products referencing this vendor:
curl "https://www.merchkit.com/api/v1/products?filter[vendor]=77e1b2aa-4f6d-4c1a-9e6b-2f8a1d3c5e70" \
  -H "Authorization: Bearer mk_live_..."

# Or the paged, key-scoped reference view (the only reader for inverse keys):
curl "https://www.merchkit.com/api/v1/products/9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21/references?attribute=used_by_kits&limit=50" \
  -H "Authorization: Bearer mk_live_..."
```

`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`.

| Operator                   | Types                    | Meaning                                                                                                         |
| -------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| *(bare)* / `eq`, `ne`      | all                      | Equals / not equals. Reference keys take UUID values                                                            |
| `contains`, `not_contains` | text                     | Substring match                                                                                                 |
| `starts_with`, `ends_with` | text                     | Prefix / suffix match                                                                                           |
| `gt`, `gte`, `lt`, `lte`   | number, date, timestamps | Comparison. Date-only values coerce to UTC midnight                                                             |
| `exists`                   | all                      | `=true` has a value, `=false` blank (replaces the old `blank`/`notBlank`)                                       |
| `in`                       | text, `id`, reference    | Comma-separated OR-of-equals, ≤ 200 values. Text matching is case-sensitive; UUID lists are validated per token |

Example one-liners:

```bash theme={null}
GET /api/v1/products?filter[sku]=ARIA-DT-72                                  # find by SKU
GET /api/v1/products?filter[description][exists]=false&filter[price][lt]=100 # missing + cheap
GET /api/v1/products?filter[sku][in]=ARIA-DT-72,ARIA-BN-18,ARIA-CH-04        # resolve 3 SKUs in 1 call
GET /api/v1/products?filter[id][in]=9b2f6c1e-…,3c77a1b9-…                    # batch refetch after events
GET /api/v1/products?filter[vendor]=77e1b2aa-…&sort=-updated_at              # by reference, newest first
```

<ParamField path="filter_join" type="and | or" default="and">
  How multiple filter clauses combine. Case-insensitive.
</ParamField>

<ParamField path="sort" type="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.
</ParamField>

Unknown keys, unknown operators, and operator/type mismatches all return `400` with
[`field_errors`](/developers/errors#validation_failed) naming the exact clause.

<Callout type="info">
  `filter[<key>][exists]=false` answers the most common enrichment question, "which products are
  missing an attribute?", and matches both never-set and cleared values.
</Callout>

## Pagination

List endpoints return rows under `data` and cursor metadata under `pagination`.

```json theme={null}
{
  "data": [ /* resources */ ],
  "pagination": { "total": 1240, "limit": 50, "offset": 0, "has_more": true }
}
```

<ParamField path="limit" type="integer" default="50">
  Page size, 1–200. Larger pages mean fewer round-trips, which agents generally prefer.
</ParamField>

<ParamField path="offset" type="integer" default="0">
  How many rows to skip. Combine with `limit` to page through results.
</ParamField>

<ParamField path="has_more" type="boolean">
  Whether more pages exist. Read `has_more` rather than computing it. It is the canonical
  signal to stop paging.
</ParamField>

## 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`.

```json theme={null}
{
  "data": [
    {
      "id": "e7c94f21-6b3a-4d8e-9f01-2c3b4a5d6e7f",
      "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": 200, "has_more": false, "next_since": "2026-07-18T09:30:22Z" }
}
```

`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

<Steps>
  <Step title="Poll with your checkpoint">
    `GET /v1/events?since=<checkpoint>&limit=200`. When `has_more` is `false`, the page holds
    every event since your checkpoint.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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).
  </Step>

  <Step title="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.
  </Step>
</Steps>

### 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:

```json theme={null}
{ "job_id": "b8d3e6f1-2a4c-4e5d-8f9a-0b1c2d3e4f5a", "status": "queued" }
```

Poll `GET /v1/jobs/{job_id}` until a terminal status (`completed`, `failed`, `cancelled`):

```json theme={null}
{
  "data": {
    "id": "b8d3e6f1-2a4c-4e5d-8f9a-0b1c2d3e4f5a",
    "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:24Z",
    "completed_at": null,
    "result": null,
    "metadata": { "workflow": "process-source" }
  }
}
```

`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`:

```bash theme={null}
# 1. Schedule → 202 { "job_id": "…", "status": "queued" }
curl -X POST "https://www.merchkit.com/api/v1/sources" \
  -H "Authorization: Bearer mk_live_..." -H "Content-Type: application/json" \
  -d '{ "attributes": { "url": "https://nordictimber.example.com/spec-sheets/aria-dt-72" } }'

# 2. Poll until completed
curl "https://www.merchkit.com/api/v1/jobs/<job_id>" -H "Authorization: Bearer mk_live_..."

# 3. Find the created source
curl "https://www.merchkit.com/api/v1/sources?filter[url]=https://nordictimber.example.com/spec-sheets/aria-dt-72" \
  -H "Authorization: Bearer mk_live_..."

# 4. Attach it to a product — an ordinary reference write
curl -X PATCH "https://www.merchkit.com/api/v1/products/9b2f6c1e-8a04-4c6e-b0d3-5f2f6f7a9e21" \
  -H "Authorization: Bearer mk_live_..." -H "Content-Type: application/json" \
  -d '{ "attributes": { "data_sources": ["<source-id>"] } }'
```

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](/developers/errors) page.

<Callout type="info">
  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.
</Callout>

## 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](#events-the-change-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.
