S SimplyPIM API

SimplyPIM API

One REST API over your whole catalogue: products and their attributes, prices, stock, images and relations — plus the parts nobody else ships, natural-language search, accessory recommendations and a storefront pixel you can point at your own shop.

Pull your catalogue

GET /v1/products with pagination, structured filters and completeness scores.

Search in plain English

GET /v1/search?q=256gb drive in stock under £200 — filters, facets, ranking.

Import by API

POST /v1/products/bulk upserts up to 500 rows by SKU with per-row results.

Sell individual units

/v1/products/:sku/units — one row per serial, with stock derived from it.

Write it once

Master/child lines resolve shared copy, spec and photography on read.

Compare across currencies

Landed cost and margin normalised into one base currency.

Getting started

Every endpoint lives under /v1 and speaks JSON both ways. The base URL is your SimplyPIM install — the same address your dashboard runs on:

Base URL
https://simplypim.co.uk

Authenticate with an API key in the X-API-Key header:

curl -s "https://simplypim.co.uk/v1/products?limit=2&status=live" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"

Where do keys come from? Sign in and open the dashboard → API, or call POST /v1/api-keys with your session. Keys are created, restricted, rotated and revoked by you — there is no ticket to raise.

Authentication

Send X-API-Key: <your key> on every request. A key looks like spim_a1b2c3_…: spim_a1b2c3 is the public prefix you see in the dashboard, and the rest is the secret.

Lost the value? You can show it again: the dashboard's API screen has a Show the key button on every key created from 2026-08-27 onwards, and the endpoint behind it is POST /v1/api-keys/:id/reveal (signed in, your own keys, and every reveal is written to the audit log). Keys older than that were only ever stored as a hash and genuinely cannot be shown — rotate those.

Testing in a browser: ?api_key=

A browser address bar cannot set a header, so every endpoint also accepts the key as a query parameter — enough to paste a URL and look at what your integration actually receives:

https://api.simplypim.co.uk/v1/search?q=camera&api_key=spim_a1b2c3_…

For looking, not for building. A query string is written to server logs, proxy logs, browser history and any Referer a linked page sends. SimplyPIM keeps it out of its own request log, audit rows and cache keys, but everything between you and us is beyond our reach — so treat a key you have pasted into a URL as one you have used in public, and rotate it if it was a secret key. The header is the form to ship.

SituationWhat happens
Valid key, allowed by its scopes and restrictions200 — the call runs and is counted against the key
No key on /v1/search, /v1/recommendations/*, works-with401 unauthorized — they require a key by default since 2026-08-27 (see below)
No key on POST /v1/events200 — the telemetry sink stays open (it discloses nothing)
No key anywhere else401 unauthorized
Unknown or rotated-away key401 invalid_api_key
Revoked key401 key_revoked
Key without the scope the route needs403 insufficient_scope
Key used from a domain or IP it is not allowed on403 origin_not_allowed / 403 ip_not_allowed
Key over its per-minute limit429 rate_limited + Retry-After

Publishable vs secret keys

There is one kind of key with two ways to use it, decided by the scopes and restrictions you give it:

KindLooks likeUse it for
Publishable search scope only, with an origin allowlist Shipping search, recommendations and events straight from a customer's browser. Safe in page source: it cannot read your catalogue, cannot write anything, and only works from the domains you list.
Secret any of read, write, import Server-side integrations, imports, ERP sync, scripts. Keep it on your server, in an environment variable — never in front-end code.

The kind field on a key tells you which you are looking at, so a UI can warn before someone pastes a secret key into a web page.

Scopes

A key carries one or more scopes; the route decides which it needs.

ScopeOpens
readEvery catalogue GET: products, categories, attributes, families, price lists, stock locations, SKU conventions, price suggestions…
writeEvery mutation: POST/PATCH/PUT/DELETE on products, categories, attributes, prices, stock, relations, enrichment
searchGET /v1/search, GET /v1/recommendations/*, POST /v1/events — the storefront trio
importPOST /v1/products/bulk (write also opens it)

Scopes do not overlap: a read+write key gets 403 insufficient_scope on /v1/search, and a search key gets the same on /v1/products. Tick every scope the integration actually needs.

Otherwise the rule is simply the verb: on any catalogue route a GET needs read and every other method needs write. That covers the newer surface too — statuses, conditions, units, lines, settings and FX and CSV export need no scope of their own.

Three groups of routes no API key can reach, whatever its scopes. Key management, saved views and user preferences are session-authed — they belong to a signed-in person, not to an integration, so they need the pim_session cookie and answer 401 unauthorized to a key. Even the operator's full-access environment key is refused. The reverse also holds: a session authorises everything a key does on the catalogue routes.

Fields returned

Scopes decide which routes a key may call. field_exposure decides which fields come back when it calls them — the same list for the search API, the rest of /v1, and the key's MCP tool payloads.

It is null by default, which means every field, and that is what every key minted before the setting does. Set it in the dashboard under API → edit the key → Fields returned, or over the API:

curl -X PATCH https://api.simplypim.co.uk/v1/api-keys/12 \
  -H "Content-Type: application/json" \
  --cookie "pim_session=..." \
  -d '{"field_exposure":["name","brand","image","price","currency","in_stock","attr.capacity_gb"]}'

A search hit for that key then carries exactly those fields:

{
  "results": [
    { "sku": "ANGELBIRD-CFXA-256", "name": "Angelbird AV PRO CFexpress Type A 256GB Card",
      "brand": "Angelbird", "image": "https://…", "price": 219, "currency": "GBP",
      "in_stock": true }
  ]
}
TokenMeans
name, price, media, …One top-level field of a product payload. GET /v1/api-keys/field-catalogue lists every one, with a label and the group it belongs to.
attributesThe whole attributes block.
attr.<code>ONE attribute. Naming any narrows the block to the ones named; naming none leaves it out entirely.
reference_values / ref.<code>The same two choices for the business's own codes and links.

Attribute and reference tokens are ADDITIVE, and they come back keyed by their code. A search hit does not normally carry attributes or reference values at all — name them here and each one lands on the row as a plain field you can read directly:

{ "sku": "CVP-ANG-0018",
  "name": "Angelbird AV PRO CFexpress Type A 256GB Card",
  "price": 207.59,
  "web_product_id": "88213",      <-- ref.web_product_id
  "capacity_gb": "256" }          <-- attr.capacity_gb

So it is hit.web_product_id, not a scan through an array for the code you just asked for. Five things follow from that:

  • Values are read through the same engine as the CSV export, so a line inherits its group's value rather than showing a blank, and a cell can never disagree with the export.
  • They are rendered as text, like a CSV cell. The full product record is untouched and still returns typed values in its own attributes / reference_values blocks — the flattening only happens where a payload carries no such block.
  • A field with no value is left out rather than sent as "", so hit.web_product_id is undefined exactly when the product has none.
  • A code that collides with a built-in field name (attr.price) is refused when the key is saved, with a message naming it — a value that could land on top of the real price is not a payload anybody can trust.
  • Bounds: up to 200 rows and 60 named codes per response, past which nothing is attached.

Four things the rule guarantees. sku is always included, whatever the list says — a record you cannot name is not a smaller answer, it is an unusable one. It is an allowlist, so a field added to the product record in a later release is left out of a key that named its fields rather than appearing unannounced. It applies to the response, not to what may be written: a write key with a narrow list still writes normally and simply gets a narrowed record back. And it takes effect on the next call — no rotation, no redeploy.

File exports are refused to a key with a field list/v1/products/export.csv, /v1/relations/export.csv, datasheet.pdf and the export/feed download and preview routes answer 403 field_exposure_conflict. They hand over whole rows through a pipeline with its own column vocabulary, so the field list cannot be applied to them, and a limit that silently stopped applying on the endpoint that dumps the entire catalogue would be worse than no limit. Read the JSON endpoints, or give the key all fields.

A key with a field list is not part of the shared storefront cache, because its bodies are not the ones every other public caller gets. Its search responses are computed per request and marked Cache-Control: private. That is a correctness requirement, not a tuning choice — but it does mean a very high-traffic shop key is cheapest left on All fields unless there is something it should not be sent.

Rows per request

A page of results is capped at 100 on /v1/search and 200 on /v1/products. Those are the right numbers for a shop and the wrong ones for a sync job, so the ceiling is a property of the key: set max_page_size and that key alone may ask for bigger pages.

curl -X PATCH https://api.simplypim.co.uk/v1/api-keys/12 \
  -H "Content-Type: application/json" --cookie "pim_session=..." \
  -d '{"max_page_size":2000}'

# then
GET /v1/products?limit=2000
GET /v1/search?q=camera&limit=2000
ValueMeaning
null (default)The install's own ceilings — 100 on search, 200 on the product list.
a number, 1–10000The largest ?limit= this key may ask for, on both endpoints.

It is a ceiling, not a page size. A request that sends no limit still returns 20 rows; this is how high limit may go. Asking for more than the ceiling is a 400 naming the maximum — a page is never silently truncated, so a paging loop can trust that a short page means the end of the results. The 10,000 hard maximum is where a bigger page stops being the right tool: past that, use CSV export or a feed.

Domain & IP restrictions

Both allowlists are empty by default, which means "no restriction". Set either and it is enforced on every request that presents the key.

Origins are matched against the Origin header, falling back to Referer when a browser omits it. Accepted entry forms:

EntryMatches
https://shop.example.comExactly that scheme + host (+ port when non-default)
shop.example.comThat host on any scheme or port
localhost:3000That host and port — handy while developing
*.example.comexample.com and any subdomain of it
*Any origin (same as an empty list, but explicit)

A key with an origin allowlist is deliberately unusable without an origin — a curl call with no Origin/Referer header gets 403 origin_not_allowed. That is the point of a domain-locked key: pass -H "Origin: https://shop.example.com" when you want to test one from the command line.

IPs accept exact addresses and CIDR blocks, IPv4 and IPv6: 203.0.113.5, 10.0.0.0/8, ::1, 2001:db8::/32. Anything else is rejected at write time with 400 bad_request.

Rate limits

Each key may carry rate_limit_per_min. It is a sliding one-minute window: the 61st request in any 60 seconds on a 60/min key is refused, and the window frees up as the old requests age out. null means no limit — recommended only for server-side keys you control.

429 rate limited
HTTP/1.1 429 Too Many Requests
retry-after: 42

{
  "error": "rate_limited",
  "message": "Rate limit of 60 requests/minute exceeded for this key",
  "limit": 60,
  "retry_after": 42
}

Honour Retry-After (seconds). Denied requests still count towards your install's usage graph, so a hammering client is visible in the dashboard.

Storefront routes & anonymous callers

Since 2026-08-27 the storefront READ routes require a key by default. GET /v1/search, GET /v1/recommendations/* and the works-with panel answer 401 unauthorized to a caller that presents none.

The reason is that everything this API says about who may see what is said about a key — its scopes, its origin lock, its rate limit and its field list. An anonymous caller has none of those, so it used to receive every field of every product no matter how carefully the key beside it had been narrowed. A shop page has a publishable key for exactly this purpose: search-scoped, domain-locked, safe in page source.

POST /v1/events stays open either way, and that is deliberate rather than an oversight: it is a write-only telemetry sink whose response says nothing about the catalogue, and it is posted by navigator.sendBeacon, which cannot set a header — which is why the pixel carries its key in the request BODY instead.

ControlEffect
storefront_requires_key (setting)Default true. Toggle it on the dashboard's API screen under “Storefront access”.

Accounts, sessions & plans

An API key is how a program authenticates. A session is how a person does. Signing up starts a 14-day trial, sets a pim_session cookie, and that cookie then authorises the same protected routes an API key does — a signed-in browser can read and write the catalogue without minting a key first. Three groups of routes go the other way and accept only a session: key management, saved views and user preferences.

Sign up, sign in, sign out

POST/v1/auth/signupopen

Creates the account, starts the trial and signs the person in. company is required — this is a B2B product and every account belongs to a business — as are email, password (8 characters minimum) and name; phone is optional.

FieldNotes
email requiredNormalised to lower case and trimmed; unique across the install
password required8 characters minimum. Stored as scrypt with a per-user salt — never recoverable, only resettable
name requiredThe person
company requiredThe business. Omitting it is a 400 validation_error on company
phoneOptional, 40 characters; blank is stored as null
curl -s -X POST "https://simplypim.co.uk/v1/auth/signup" \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{
    "email": "ops@northlightcameras.co.uk",
    "password": "correct-horse-battery",
    "name": "Dana Okoro",
    "company": "Northlight Cameras",
    "phone": "+44 20 7946 0812"
  }'
201 response
{
  "user": {
    "id": 1,
    "email": "ops@northlightcameras.co.uk",
    "name": "Dana Okoro",
    "company": "Northlight Cameras",
    "phone": "+44 20 7946 0812",
    "role": "owner"
  },
  "trial": {
    "ends_at": "2026-08-13 01:38:38",
    "days_left": 14,
    "active": true
  },
  "subscription": null,
  "access": true
}
the cookie it sets
set-cookie: pim_session=2b64ba3ec91e417201607d4e7d0f4319…; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax
409 that email already has an account
{ "error": "email_taken", "message": "An account with that email already exists" }
400 company omitted
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "company", "message": "Required" }
  ]
}

The first account on an install is the owner — the person who stood it up. Every later self-service signup is an editor, which can read and write the catalogue but cannot reach the owner-only routes (/v1/accounts, the diagnostics support bundle, or a diagnostics run that writes). role is always one of owner, editor, viewer.

POST/v1/auth/loginopen

Exchanges email + password for a fresh session cookie, and answers with the same account payload as signup. Logging in again does not invalidate the previous session — each login mints its own token, so a person can be signed in on a laptop and a phone at once.

curl -s -X POST "https://simplypim.co.uk/v1/auth/login" \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{ "email": "ops@northlightcameras.co.uk", "password": "correct-horse-battery" }'
200 response
HTTP/1.1 200 OK
set-cookie: pim_session=27c10da99c17318d9d6eae2b3a4cbf14…; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax

{
  "user": {
    "id": 1,
    "email": "ops@northlightcameras.co.uk",
    "name": "Dana Okoro",
    "company": "Northlight Cameras",
    "phone": "+44 20 7946 0812",
    "role": "owner"
  },
  "trial": { "ends_at": "2026-08-13 01:38:38", "days_left": 14, "active": true },
  "subscription": null,
  "access": true
}

One 401 for every failure, on purpose. A wrong password and an email that has never signed up return the identical status and body, so the endpoint cannot be used to discover who has an account:

{ "error": "invalid_credentials", "message": "Incorrect email or password" }

Do not "helpfully" split that into no such user and wrong password in your own UI — that reintroduces the enumeration the API is avoiding.

GET/v1/auth/mesession

Who am I, and may I still use this? The route is reachable without a credential (so a front end can ask "am I signed in?" without handling a network error) but answers 401 when there is no valid session. Poll it on app boot: access is the single boolean a front end should gate on.

curl -s "https://simplypim.co.uk/v1/auth/me" -b cookies.txt
200 response — trial running, a plan requested
{
  "user": {
    "id": 1,
    "email": "ops@northlightcameras.co.uk",
    "name": "Dana Okoro",
    "company": "Northlight Cameras",
    "phone": "+44 20 7946 0812",
    "role": "owner"
  },
  "trial": {
    "ends_at": "2026-08-13 01:38:38",
    "days_left": 14,
    "active": true
  },
  "subscription": {
    "plan_id": "growth",
    "plan_name": "Growth",
    "product_limit": 10000,
    "price_monthly_gbp": 149,
    "status": "pending",
    "started_at": "2026-07-30 01:39:14"
  },
  "access": true
}
401 no session
{ "error": "unauthorized", "message": "Sign in to continue" }
FieldMeaning
trial.ends_atUTC timestamp, 14 days after signup
trial.days_leftCounts the part-day you are in, so a fresh signup reads 14 and the final hours still read 1. 0 once it has lapsed
trial.activetrue while ends_at is in the future
subscriptionThe active subscription, else the outstanding pending request, else null. Cancelled history is never surfaced
accessThe one to gate on: trial.active or a subscription whose status is active
POST/v1/auth/logoutopen

Deletes the session row and clears the cookie. 204 with no body, and it is safe to call without a session — logging out twice is not an error.

curl -s -i -X POST "https://simplypim.co.uk/v1/auth/logout" -b cookies.txt
204 response
HTTP/1.1 204 No Content
set-cookie: pim_session=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax

The pim_session cookie

AttributeValueWhy
HttpOnlysetJavaScript cannot read it, so an XSS bug cannot exfiltrate the session
SameSiteLaxSent on top-level navigation, withheld on cross-site sub-requests
Path/Covers the API and the dashboard
Max-Age259200030 days. The server-side row expires at the same moment, so an old cookie cannot be replayed
Securenot setSo the zero-config HTTP dev server works. Terminate TLS in front of a production install

The token is 32 random bytes; only its SHA-256 is stored, so a database leak cannot be replayed as a login. Because the cookie is HttpOnly, a browser client never handles the value itself — send credentials: 'same-origin' (or 'include' cross-origin) and let the browser attach it.

Plans & the trial

There is no payment integration, and no self-service checkout. Plans are metadata and limits: a name, a monthly price to quote, a product ceiling and a feature list. POST /v1/account/subscribe records a request; the owner invoices the customer out-of-band and activates the subscription by hand. Nothing on this page takes a card number, and no endpoint here talks to a payment provider.

GET/v1/plansopen

The plan catalogue, ordered for display. Open with no credential so a pricing page can render straight from the API.

curl -s "https://simplypim.co.uk/v1/plans"
200 response
{
  "plans": [
    {
      "id": "starter",
      "name": "Starter",
      "price_monthly_gbp": 39,
      "product_limit": 1000,
      "features": ["Up to 1,000 products", "AI enrichment", "NL search", "Email support"]
    },
    {
      "id": "growth",
      "name": "Growth",
      "price_monthly_gbp": 149,
      "product_limit": 10000,
      "features": [
        "Up to 10,000 products",
        "AI enrichment",
        "NL search",
        "Accessory pixel",
        "Price suggestions",
        "Priority support"
      ]
    },
    {
      "id": "scale",
      "name": "Scale",
      "price_monthly_gbp": 399,
      "product_limit": 100000,
      "features": [
        "Up to 100,000 products",
        "Everything in Growth",
        "Dedicated support",
        "Custom integrations"
      ]
    }
  ]
}
POST/v1/account/subscribesession

Records interest in a plan. The row lands as pending and grants no access — it is a to-do for the owner, not a checkout. Sending it again replaces the earlier pending request, so a customer changing their mind leaves one row, not two.

FieldNotes
plan_id requiredAn id from GET /v1/plans. Unknown ids are 404 plan_not_found
curl -s -X POST "https://simplypim.co.uk/v1/account/subscribe" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{ "plan_id": "growth" }'
200 response
{
  "subscription": {
    "plan_id": "growth",
    "plan_name": "Growth",
    "product_limit": 10000,
    "price_monthly_gbp": 149,
    "status": "pending",
    "started_at": "2026-07-30 01:39:14"
  }
}
404 no such plan
{ "error": "plan_not_found", "message": "Plan 'enterprise' not found" }
statusMeans
pendingRequested. No access granted; the owner has been handed a to-do
activeThe owner activated it after invoicing. This is what keeps access true once the trial lapses
canceledSuperseded or withdrawn. Never surfaced to the customer

Plan limits

Two ceilings exist, and both are off unless you turn them on. They are enforced by the install, not billed for.

The product ceiling

Set enforce_plan_limits to true and product creation is refused once the catalogue is full. The ceiling is the active subscription's product_limit, or — when there is no subscription, which is the normal case — the manual product_limit setting. 0 means no limit.

409 plan_limit_reached on POST /v1/products
{
  "error": "plan_limit_reached",
  "message": "This install is limited to 10 products (product_limit setting) and already has 37. Remove products, raise the limit, or turn off enforce_plan_limits.",
  "limit": 10,
  "count": 37,
  "plan": "product_limit setting"
}
RouteGated?
POST /v1/productsYes — 409 plan_limit_reached
POST /v1/products/:sku/linesYes — a child line is a product, and counts as one
POST /v1/products/bulkNo — a bulk call is mostly updates, and refusing the whole batch because the catalogue is full would be wrong. Rows that would create beyond the ceiling are refused individually in the per-row results
Every PATCH / PUT / DELETENo — the ceiling is on how many products exist, not on editing the ones you have

Two error paths share the plan_limit_reached code. The opt-in gate above answers 409. Separately, an install with an active subscription enforces that plan's ceiling from inside the products module with a 402. Match on the error string and read limit and count rather than branching on the status.

The API-key quota

api_key_quota_per_user (default 20) caps how many live keys one person may hold; revoked and expired keys do not count. GET /v1/api-keys reports where you stand in a quota block, so a UI can grey out "New key" before the call fails.

409 key_quota_exceeded on POST /v1/api-keys
{
  "error": "key_quota_exceeded",
  "message": "You already hold 1 of 1 allowed API keys — revoke one, or raise the 'api_key_quota_per_user' setting.",
  "details": { "quota": 1, "count": 1 }
}
200 the quota block on GET /v1/api-keys
{ "quota": { "limit": 1, "used": 1 } }

Usage

GET/v1/account/usageread

Everything the dashboard's overview draws: product count against the plan ceiling, 30 days of API calls and pixel events, and the completeness distribution across the whole catalogue. Reachable with either credential — a session reports that user's active subscription, an API key falls back to the install's.

FieldNotes
products.countEvery product row, child lines included
products.limitThe active subscription's ceiling, or null. Trials are unlimited, so a trialling account reads null
plannull until a subscription is active — a pending request does not appear here
api_calls_daily30 entries, ascending, zero-filled. Counts every /v1 request including refused ones
events_daily30 entries, ascending, zero-filled — pixel telemetry
completenessMean score and four buckets, scored with the same logic as GET /v1/products — including attribute sets
curl -s "https://simplypim.co.uk/v1/account/usage" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (daily series trimmed)
{
  "products": { "count": 37, "limit": null },
  "plan": null,
  "api_calls_daily": [
    { "day": "2026-07-28", "count": 0 },
    { "day": "2026-07-29", "count": 0 },
    { "day": "2026-07-30", "count": 16 }
  ],
  "events_daily": [
    { "day": "2026-07-28", "count": 0 },
    { "day": "2026-07-29", "count": 0 },
    { "day": "2026-07-30", "count": 0 }
  ],
  "completeness": {
    "average": 93.1,
    "buckets": [
      { "range": "0-25",   "count": 2 },
      { "range": "25-50",  "count": 1 },
      { "range": "50-75",  "count": 0 },
      { "range": "75-100", "count": 34 }
    ]
  }
}

Owner: every account

GET/v1/accountsowner

Every account on the install, newest first, each with its trial and current subscription — the Customers desk in the dashboard. Owner only. An ordinary signed-in account gets 403, not 401: they are authenticated perfectly well, just not allowed here. The environment API key always passes, so scripts and CI work.

curl -s "https://simplypim.co.uk/v1/accounts" -b cookies.txt
200 response
{
  "accounts": [
    {
      "id": 2,
      "email": "buyer@northlightcameras.co.uk",
      "name": "Priya Raman",
      "company": "Northlight Cameras",
      "phone": null,
      "role": "editor",
      "created_at": "2026-07-30 01:39:24",
      "trial": { "ends_at": "2026-08-13 01:39:24", "days_left": 14, "active": true },
      "subscription": null
    },
    {
      "id": 1,
      "email": "ops@northlightcameras.co.uk",
      "name": "Dana Okoro",
      "company": "Northlight Cameras",
      "phone": "+44 20 7946 0812",
      "role": "owner",
      "created_at": "2026-07-30 01:38:38",
      "trial": { "ends_at": "2026-08-13 01:38:38", "days_left": 14, "active": true },
      "subscription": {
        "plan_id": "growth",
        "plan_name": "Growth",
        "product_limit": 10000,
        "price_monthly_gbp": 149,
        "status": "pending",
        "started_at": "2026-07-30 01:39:14"
      }
    }
  ],
  "roles": ["owner", "editor", "viewer"],
  "role_counts": { "owner": 1, "editor": 1, "viewer": 0 }
}
403 a non-owner session
{
  "error": "forbidden",
  "message": "Your role 'editor' cannot read accounts and invites — 'owner' is required.",
  "role": "editor",
  "required_role": "owner"
}

Owner routes for activating a subscription after invoicing, deactivating an account, changing a role and minting colleague invites exist alongside this one. They are administrative rather than integration surface, so they are driven from the dashboard's Customers desk rather than documented here — the payload above tells you everything the desk shows.

Products

A product is the SKU plus everything hanging off it. The full record embeds its family, categories, typed attributes, prices, stock, relations, images and a completeness score with a missing[] breakdown, so one GET is usually all a storefront or ERP needs.

GET/v1/productsread

Paged list with a lightweight projection (price, free stock, primary image, completeness).

QueryNotes
statusA statuses.codedraft, live, discontinued and archived out of the box, plus any you define. CSV or repeated
conditionA conditions.codenew, used, demo, showroom out of the box. CSV or repeated
categoryCategory slug or id; includes the category's direct children, and matches any membership (not just the primary one)
brandCase-insensitive exact match. CSV or repeated
familyFamily code, e.g. camera
qSubstring match on SKU, name and brand, widened by the same term expansion /v1/search uses. For ranked results and facets use /v1/search
sort / ordername | sku | updated | completeness | price, and asc (default) | desc
limit / offsetDefault 20, max 200 / default 0

That is the short list. The same endpoint also takes structured filters — multi-category with descendants, completeness bands, stock and price ranges, date windows and per-attribute predicates like attr.mount=Sony E&attr.weight_g__lt=1000. The full reference is Advanced find.

curl -s "https://simplypim.co.uk/v1/products?limit=2&status=live" \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "count": 35,
  "limit": 2,
  "offset": 0,
  "results": [
    {
      "id": 10,
      "sku": "RED-KOMODO6K",
      "name": "RED KOMODO 6K Digital Cinema Camera",
      "brand": "RED",
      "status": "live",
      "status_meta": { "code": "live", "label": "Live", "color": "#16a34a", "visible_public": true },
      "condition": "new",
      "condition_meta": { "code": "new", "label": "New", "color": "#16a34a", "sku_token": "N" },
      "master": null,
      "completeness": 100,
      "image": "https://placehold.co/400x300?text=RED-KOMODO6K",
      "price": 5495,
      "deal_price": null,
      "currency": "GBP",
      "free_stock": 0
    },
    {
      "id": 4,
      "sku": "CANON-C70",
      "name": "Canon EOS C70 Super 35 Cinema Camera",
      "brand": "Canon",
      "status": "live",
      "status_meta": { "code": "live", "label": "Live", "color": "#16a34a", "visible_public": true },
      "condition": "new",
      "condition_meta": { "code": "new", "label": "New", "color": "#16a34a", "sku_token": "N" },
      "master": null,
      "completeness": 100,
      "image": "https://placehold.co/400x300?text=CANON-C70",
      "price": 4499,
      "deal_price": null,
      "currency": "GBP",
      "free_stock": 5
    }
  ]
}

price is the web-uk list price and deal_price is an active deal (null when there is none) — the amount actually payable is deal_price ?? price. Those two and currency always come from web-uk: price_list changes what is filtered and sorted, never which line is projected here. free_stock is qty − allocated across every location, never scoped by location_id. status_meta and condition_meta carry the label and colour of the status / condition so a grid needs no lookup per row, and master is null unless the row is a child line. brand and image are resolved through the master, so an inheriting line never reads blank.

GET/v1/products/:skuread

The full record. 404 not_found when the SKU is unknown.

curl -s https://simplypim.co.uk/v1/products/SONY-FX3 \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response (trimmed)
{
  "id": 1,
  "sku": "SONY-FX3",
  "name": "Sony FX3 Full-Frame Cinema Line Camera",
  "brand": "Sony",
  "status": "live",
  "status_meta": { "code": "live", "label": "Live", "color": "#16a34a", "visible_public": true },
  "condition": "new",
  "condition_meta": { "code": "new", "label": "New", "color": "#16a34a", "sku_token": "N" },
  "description": "Compact full-frame Cinema Line camera with a 10.2MP back-illuminated Exmor R sensor…",
  "has_raw_source_text": true,
  "keywords": ["gimbal camera", "wedding videography", "b camera"],
  "auto_keywords": ["sony fx3", "fx3", "sony fx-3", "cine camera", "e-mount", "full frame"],
  "created_at": "2026-07-30 01:02:20",
  "updated_at": "2026-07-30 01:07:52",
  "family": {
    "id": 1,
    "code": "camera",
    "label": "Cameras",
    "attributes": [
      { "code": "max_resolution", "label": "Max Recording Resolution", "type": "select", "unit": null, "required": true }
      // … the rest of the family schema
    ]
  },
  "category": {
    "id": 2,
    "name": "Cine Cameras",
    "slug": "cine-cameras",
    "parent_id": 1,
    "parent_slug": "cameras",
    "path": "Cameras / Cine Cameras",
    "product_count": 6
  },
  "categories": [
    { "id": 2, "name": "Cine Cameras", "slug": "cine-cameras", "path": "Cameras / Cine Cameras", "primary": true,  "position": 0 },
    { "id": 1, "name": "Cameras",      "slug": "cameras",      "path": "Cameras",                "primary": false, "position": 1 }
  ],
  "attributes": [
    { "id": 29, "code": "warranty_months", "label": "Warranty", "type": "number", "unit": null, "options": null, "group": "compliance", "quick_filter": false, "value": 24, "required": false },
    { "id": 30, "code": "show_on_web", "label": "Show on Web", "type": "boolean", "unit": null, "options": null, "group": "flags", "quick_filter": true, "value": false, "required": false }
  ],
  "prices": [
    {
      "price_list": "b2b-trade", "label": "B2B Trade", "kind": "sell", "position": 0,
      "currency": "GBP", "channel": "b2b",
      "amount": 2743.5, "cost": 2717.31, "rrp": null,
      "margin_pct": 1, "deal": null, "effective_amount": 2743.5,
      "duty_pct": null, "shipping": null, "other": null, "landed": null,
      "margin": { "value_minor": 2619, "pct": 1, "basis": "cost", "vs_list": 1 },
      "normalized": {
        "amount_minor": 274350, "cost_minor": 271731, "rrp_minor": null,
        "deal_amount_minor": null, "effective_amount_minor": 274350, "landed_minor": null,
        "margin_pct": 1,
        "margin": { "value_minor": 2619, "pct": 1, "basis": "cost", "vs_list": 1 },
        "cost_source": "line",
        "currency": "GBP", "rate": 1, "rate_mode": "base", "as_of": null, "stale": false
      }
    }
    // … one entry per price line, sell lines first (see Prices)
  ],
  "stock": [
    { "location": "NEW-LON", "location_name": "London Warehouse", "qty": 60, "allocated": 12, "free": 48 },
    { "location": "SHOWROOM", "location_name": "London Showroom", "qty": 6, "allocated": 0, "free": 6 }
  ],
  "stock_totals": { "qty": 66, "allocated": 12, "free": 54 },
  "serial_tracked": false,
  "units_summary": null,
  "relations": {
    "children": [
      { "type": "accessory_of", "sku": "SONY-NPFZ100", "name": "Sony NP-FZ100 Rechargeable Battery Pack", "brand": "Sony", "status": "live", "position": 0 }
    ],
    "parents": []
  },
  "media": [
    { "id": 1, "url": "https://placehold.co/400x300?text=SONY-FX3", "kind": "image", "position": 0 }
  ],
  "completeness": { "score": 100, "missing": [] },
  "master": null,
  "children": [
    {
      "id": 11, "sku": "SONY-FX3-DEMO", "name": "Sony FX3 Full-Frame Cinema Line Camera (Ex-Demo)",
      "condition": "demo", "status": "live",
      "overridden_fields": ["description", "brand", "family", "media", "attr.warranty_months"]
    }
  ]
}

Four blocks are worth knowing about before you write a consumer. serial_tracked / units_summary are the unit aggregate (units_summary is null on a product that is not serial-tracked). master / children are the inheritance links — both always reported, and a child payload gains a fifth block, resolved. keywords / auto_keywords are the two keyword lists. Every price line carries normalized and margin — see Landed cost & margin.

POST/v1/productswrite

Creates one product and returns 201 with the full record. Omit sku and the matching SKU convention assigns one; send one and it always wins (a non-matching manual SKU comes back with non-blocking warnings).

FieldNotes
name requiredUp to 300 characters
skuUp to 64 characters, unique. Omit to auto-generate
brand, description, raw_source_textFree text. raw_source_text is supplier copy for enrichment
familyFamily code — drives the attribute schema and completeness
categoryPrimary category by slug
category_ids + primary_category_idFull multi-category assignment by id
statusAny statuses.codedraft, live, discontinued, archived out of the box, plus your own. Omitted = the is_default status (draft as shipped). An undefined code is 400 listing the valid ones
conditionAny conditions.codenew (the column default), used, demo, showroom out of the box, plus your own
keywordsUp to 50 manual search keywords, weighted just below the name in search. Replaces the list; [] clears it. The record also returns read-only auto_keywords from enrichment
serial_trackedtrue makes stock the sum of the product's units. Turning it back off is refused while units exist
master / master_idThe SKU (or id) of the master this product is a child line of; null = standalone. master wins when both are sent
curl -s -X POST https://simplypim.co.uk/v1/products \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "sku": "NLAV-DOCS-1",
        "name": "Northlight Docs Demo Cage",
        "brand": "Northlight",
        "category": "camera-support",
        "status": "draft",
        "condition": "new",
        "description": "Example product created from the docs."
      }'
201 response (trimmed)
{
  "id": 34,
  "sku": "NLAV-DOCS-1",
  "name": "Northlight Docs Demo Cage",
  "brand": "Northlight",
  "status": "draft",
  "condition": "new",
  "description": "Example product created from the docs.",
  "created_at": "2026-07-29 22:43:42",
  "updated_at": "2026-07-29 22:43:42",
  "category": {
    "id": 9, "name": "Camera Support & Rigging", "slug": "camera-support",
    "parent_id": null, "parent_slug": null, "path": "Camera Support & Rigging",
    "product_count": 5
  },
  "attributes": [], "prices": [], "stock": [], "media": [],
  "completeness": { "score": 60, "missing": ["description", "media", "price", "stock"] },
  "warnings": ["'NLAV-DOCS-1' does not match the global SKU convention '{BRAND:3}-{SEQ}'"]
}

warnings is present only when there is something to say. A duplicate SKU is 409 conflict; an unknown family or category slug is 404 not_found.

PATCH/v1/products/:skuwrite

Partial update — send only what changes. Accepts every create field plus sku (renames the product, uniqueness still enforced) and inherit: [...], which hands named fields back to the master. Returns the full record.

curl -s -X PATCH https://simplypim.co.uk/v1/products/NLAV-DOCS-1 \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "live", "category_ids": [9, 3], "primary_category_id": 9}'
200 response (trimmed)
{
  "id": 34,
  "sku": "NLAV-DOCS-1",
  "status": "live",
  "categories": [
    { "id": 9, "name": "Camera Support & Rigging", "slug": "camera-support", "path": "Camera Support & Rigging", "primary": true, "position": 0 },
    { "id": 3, "name": "Mirrorless Cameras", "slug": "mirrorless-cameras", "path": "Cameras / Mirrorless Cameras", "primary": false, "position": 1 }
  ],
  "completeness": { "score": 100, "missing": [] }
  // … the same shape as GET /v1/products/:sku
}
DELETE/v1/products/:skuwrite

Deletes the product and everything attached to it (attributes, prices, stock, relations, media, search index row). Answers 204 with no body.

curl -s -o /dev/null -w '%{http_code}\n' \
  -X DELETE https://simplypim.co.uk/v1/products/NLAV-DOCS-1 \
  -H "X-API-Key: $SIMPLYPIM_KEY"
# 204

Images

A product's images are an ordered list. PUT replaces the whole list; position defaults to the array index and the lowest position is the primary image.

Every row carries a source: "url" for an image you point at, or "upload" for bytes this install holds. A URL row reports storage_key, bytes, width, height, checksum and created_at as null and variants_supported: false — the install does not have the file, so it cannot describe or resize it. An uploaded row fills all of those in and carries variants:

200 the two shapes side by side
{
  "sku": "SONY-FX3",
  "media": [
    {
      "id": 1,
      "url": "https://placehold.co/400x300?text=SONY-FX3",
      "kind": "image",
      "position": 0,
      "alt": null,
      "source": "url",
      "storage_key": null,
      "content_type": null,
      "bytes": null,
      "width": null,
      "height": null,
      "checksum": null,
      "variants": {},
      "variants_supported": false,
      "variants_note": "external URL — this install does not hold the bytes, so no variants exist",
      "created_at": null
    },
    {
      "id": 38,
      "url": "/media/p1/d3685161f57c/original.png",
      "kind": "image",
      "position": 1,
      "alt": "Sony FX3 body, three-quarter view on a neutral background",
      "source": "upload",
      "storage_key": "p1/d3685161f57c/original.png",
      "content_type": "image/png",
      "bytes": 2560,
      "width": 900,
      "height": 600,
      "checksum": "d3685161f57c8c54da383c822b1afc4b878f11e6880faa61d2dd30756d8f1f17",
      "variants": {
        "thumb":  { "url": "/media/p1/d3685161f57c/thumb.png",  "width": 96,  "height": 64,  "bytes": 311,  "content_type": "image/png" },
        "small":  { "url": "/media/p1/d3685161f57c/small.png",  "width": 320, "height": 213, "bytes": 700,  "content_type": "image/png" },
        "medium": { "url": "/media/p1/d3685161f57c/medium.png", "width": 800, "height": 533, "bytes": 2225, "content_type": "image/png" }
      },
      "variants_supported": true,
      "variants_note": null
    }
  ]
}

Two ways to put an image on a product. PUT /v1/products/:sku/media (below) replaces the whole list from URLs you host. Everything under uploads sends bytes this install stores — content-addressed, deduplicated and served by GET /media/*. The two mix freely in one gallery.

Read GET /v1/media/config before you write an uploader against this. It is the self-describing source of truth for the size cap, the accepted types and whether resize variants exist at all on the install you are talking to — all three are install- and build-dependent, so trust it over any number printed on this page.

PUT/v1/products/:sku/mediawrite
curl -s -X PUT https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"media": [
        {"url": "https://cdn.example.com/cage-front.jpg", "kind": "image", "position": 0},
        {"url": "https://cdn.example.com/cage-side.jpg",  "kind": "image", "position": 1}
      ]}'
200 response
{
  "sku": "NLAV-DOCS-1",
  "media": [
    { "id": 33, "url": "https://cdn.example.com/cage-front.jpg", "kind": "image", "position": 0 },
    { "id": 34, "url": "https://cdn.example.com/cage-side.jpg",  "kind": "image", "position": 1 }
  ]
}

GET /v1/products/:sku/media returns the same shape.

Uploading files

POST /v1/products/:sku/media takes the bytes themselves. The install stores them under a content-addressed key, records the sniffed MIME type, generates resize variants where it can, and answers with the new row plus the product's whole gallery. Identical bytes are never stored twice.

Four things about this endpoint routinely surprise people. All four are deliberate.

1. Resize variants are PNG-only today. A JPEG, WebP, AVIF or GIF uploads and stores perfectly — real dimensions, real bytes, served like any other — but comes back with variants: {}, variants_supported: false and a variants_note saying why. See the captured responses.

2. SVG is refused with 415 — it is not sanitised. There is no scrubbing step and none is planned. Why.

3. The MIME type comes from the magic bytes, never the filename. A file called .png that contains JPEG bytes is stored as image/jpeg under original.jpg. See it happen.

4. There is a hard size cap — 15 MB as shipped. Do not hard-code that number: read max_upload_bytes from GET /v1/media/config, which also lists the accepted types.

POST/v1/products/:sku/mediawrite

Three request shapes, all the same endpoint, chosen by Content-Type. Send whichever your client can produce — the stored result is identical.

Content-TypeWhere the bytes and the metadata go
multipart/form-dataBytes in a file part. alt, kind, position and filename as sibling text parts. The browser default; also the only shape that carries a filename for free. If no part is named file the first file part is used, and a body with no file part at all is a 400
application/octet-stream
or any image/*
The raw bytes are the whole body — curl --data-binary @photo.png. Metadata goes in the query string: ?filename=&alt=&kind=&position=
application/json{"content_base64": "…"}, or {"data_url": "data:image/png;base64,…"}. For iPaaS/low-code clients that cannot do either of the above. Metadata are sibling JSON keys
FieldNotes
file requiredMultipart only — the bytes. The other two shapes carry the bytes in the body itself
altAlt text, up to 1000 characters. null clears it. Supplying it on a deduped re-upload updates the existing row — new information is still new information
kindUp to 32 characters, defaults to "image". Free-form, so "lifestyle" or "dimension-drawing" are yours to define
position0-based insert index. Omitted = append. An explicit position pushes the rest of the gallery down and renumbers it, so positions stay dense. Lowest position is the primary image
filenameRecorded for reference only. It never determines the MIME type or the stored extension — see sniffing

Metadata sent in the query string is also honoured by the multipart and JSON shapes; a body field wins over the query string when both are present.

# 1 — multipart/form-data
curl -s -X POST https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -F "file=@hand-unit.png" \
  -F "alt=Nucleus Lite hand unit, three-quarter view"

# 2 — raw bytes, metadata in the query string
curl -s -X POST "https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media?filename=motor-detail.png&alt=Detail%20crop%20of%20the%20motor%20mount" \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @motor-detail.png

# 3 — JSON with base64
curl -s -X POST https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\":\"lens-mount.png\",\"alt\":\"0.8 mod gear ring on the lens\",
       \"content_base64\":\"$(base64 < lens-mount.png | tr -d '\n')\"}"

In the browser, let fetch set Content-Type itself when you pass a FormData. Setting it by hand drops the multipart boundary and the request fails as a malformed body.

201 stored — a 1200×800 PNG, all three variants generated
{
  "sku": "NLAV-DOCS-1",
  "deduped": false,
  "item": {
    "id": 39,
    "url": "/media/p38/d11b3aae2b72/original.png",
    "kind": "image",
    "position": 0,
    "alt": "Nucleus Lite hand unit, three-quarter view",
    "source": "upload",
    "storage_key": "p38/d11b3aae2b72/original.png",
    "content_type": "image/png",
    "bytes": 122538,
    "width": 1200,
    "height": 800,
    "checksum": "d11b3aae2b72c6596ef503c298bd51173136c2fe180fb864dddcf3d454323e26",
    "variants": {
      "thumb":  { "url": "/media/p38/d11b3aae2b72/thumb.png",  "key": "p38/d11b3aae2b72/thumb.png",  "width": 96,  "height": 64,  "bytes": 196,  "content_type": "image/png" },
      "small":  { "url": "/media/p38/d11b3aae2b72/small.png",  "key": "p38/d11b3aae2b72/small.png",  "width": 320, "height": 213, "bytes": 666,  "content_type": "image/png" },
      "medium": { "url": "/media/p38/d11b3aae2b72/medium.png", "key": "p38/d11b3aae2b72/medium.png", "width": 800, "height": 533, "bytes": 2934, "content_type": "image/png" }
    },
    "variants_supported": true,
    "variants_note": null,
    "created_at": "2026-07-30 02:20:31"
  },
  "media": [
    // … the product's whole gallery, same row shape, in position order
  ]
}

item is the row you just created; media is the whole gallery after the write, so a UI never needs a follow-up GET. The first twelve hex characters of checksum are the middle path segment of every key the asset owns — that is what makes it immutable and safely cacheable.

201 a PNG smaller than every variant size
{
  "id": 40,
  "url": "/media/p38/76b6f4ccd4bd/original.png",
  "content_type": "image/png",
  "bytes": 2987,
  "width": 48,
  "height": 32,
  "checksum": "76b6f4ccd4bda153b4bea2fb77ce3ae9f625b7fad665504d6c1f5f863e340211",
  "variants": {},
  "variants_supported": true,
  "variants_note": "original is 48x32, smaller than every variant size"
}

Note variants_supported: true with an empty variants: the format can be resized here, but nothing was produced because variants are never upscaled. That is a different situation from a format with no decoder, and the two are distinguishable without parsing prose.

200 deduped — the same bytes, uploaded a second time
{
  "sku": "NLAV-DOCS-1",
  "deduped": true,
  "item": {
    "id": 39,
    "url": "/media/p38/d11b3aae2b72/original.png",
    "position": 0,
    "checksum": "d11b3aae2b72c6596ef503c298bd51173136c2fe180fb864dddcf3d454323e26",
    "bytes": 122538
    // … byte-for-byte the row the first upload created
  },
  "media": [ /* unchanged */ ]
}

200 instead of 201 is the whole dedupe signal. Uploads are keyed by the SHA-256 of the bytes. If a row on this product already has that checksum, no second row is created, no second object is written, and the response is 200 with deduped: true and the existing item. A retried upload after a dropped connection is therefore safe and idempotent — and an importer can re-run without growing the gallery.

Dedupe is per product, not global. The same photo on a second product gets its own row and its own object, because keys are namespaced p<productId>/… so deleting one product's media can never break another's. The same bytes, posted to two different SKUs — note the identical checksum segment, two keys, and deduped: false both times:

// product 38
{ "deduped": false, "item": { "id": 41, "storage_key": "p38/cf31c8942a01/original.png" } }

// product 39, byte-for-byte the same file
{ "deduped": false, "item": { "id": 44, "storage_key": "p39/cf31c8942a01/original.png" } }
415 413 400 404 the refusals
// 415 — SVG, refused rather than sanitised
{ "error": "unsupported_media_type", "message": "SVG uploads are refused: an SVG served from this origin can execute script. Rasterise it (PNG/WebP) first, or keep it on a CDN and add it as a url-only media row." }

// 415 — the bytes are not an image this build recognises
{ "error": "unsupported_media_type", "message": "Could not identify these bytes as an image. Accepted: image/png, image/jpeg, image/webp, image/avif, image/gif" }

// 413 — over the cap (15 MB as shipped)
{ "error": "payload_too_large", "message": "File is 16785088 bytes; the limit is 15728640 (contact support to accept larger files)" }

// 400 — an empty body
{ "error": "bad_request", "message": "The uploaded file is empty" }

// 400 — multipart with metadata but no bytes
{ "error": "bad_request", "message": "multipart body carried no file part (expected a `file` field)" }

// 404 — unknown SKU
{ "error": "not_found", "message": "Product 'NO-SUCH-SKU' not found" }

The order of checks is size, then magic bytes, then the allowlist, then the dedupe probe — so an oversized file is rejected before anything is decoded, and nothing reaches storage until every check has passed.

413 there are two shapes of "too large" — handle both
// just over the upload limit — the service explains itself
{ "error": "payload_too_large", "message": "File is 16785088 bytes; the limit is 15728640 (contact support to accept larger files)" }

// far over it (~22 MB+) — the transport cuts the request off before the handler sees a byte
{ "error": "FST_ERR_CTP_BODY_TOO_LARGE", "message": "Request body is too large" }

Both are 413, but only the first carries the limit. Branch on the status code, not on error, and take the cap itself from max_upload_bytes. The transport ceiling sits above the configured cap on purpose, so base64's 4/3 inflation cannot make a legal file fail for the wrong reason.

Variants are generated for PNG only

The resize pipeline is hand-written on node:zlib, because PNG is the one raster format whose entire compression layer already ships inside Node. JPEG, WebP, AVIF and GIF would each need a full entropy decoder and an encoder; rather than hand-roll those and risk silently producing wrong pixels, this build produces none for them and says so. All five formats upload, store, serve and report real dimensions either way.

201 a real 1200×800 JPEG upload — stored fine, no variants
{
  "sku": "NLAV-DOCS-1",
  "deduped": false,
  "item": {
    "id": 42,
    "url": "/media/p38/66413a641b55/original.jpg",
    "kind": "image",
    "position": 3,
    "alt": "Nucleus Lite motor on a 15mm rod",
    "source": "upload",
    "storage_key": "p38/66413a641b55/original.jpg",
    "content_type": "image/jpeg",
    "bytes": 52895,
    "width": 1200,
    "height": 800,
    "checksum": "66413a641b55de754ac6ec8fa42b366bfbeb19a4e617520c959b9035c1e37548",
    "variants": {},
    "variants_supported": false,
    "variants_note": "no built-in decoder for image/jpeg — variants are generated for image/png only",
    "created_at": "2026-07-30 02:20:31"
  }
}
201 the same for a 1200×800 GIF
{
  "id": 45,
  "url": "/media/p39/c4c70f606786/original.gif",
  "content_type": "image/gif",
  "bytes": 49403,
  "width": 1200,
  "height": 800,
  "variants": {},
  "variants_supported": false,
  "variants_note": "no built-in decoder for image/gif — variants are generated for image/png only"
}

image/webp and image/avif behave identically — both are in allowed_content_types and both sit in variants.passthrough, which is the authoritative list. Dimensions are read from the container header for all five formats, so width and height are real even when no variant is produced.

Do not build a UI that assumes variants.thumb exists. Branch on variants_supported, and fall back to url (the original) when it is false or when variants is empty. The compensation for the limitation is that it never lies: no variant is ever reported that was not actually produced, so a URL under variants is always a file that exists and you never have to probe it.

A PNG can also legitimately yield no variants. Every such case is a successful upload carrying a variants_note that says which one it was — never a failure. These are the four values you will actually see:

variants_supportedvariants_noteMeaning
truenullVariants were generated. variants holds them
true"original is 48x32, smaller than every variant size"PNG, resizable here, but variants are never upscaled — so none were made
false"no built-in decoder for image/jpeg — variants are generated for image/png only"A passthrough format: stored and served, not resizable on this build
false"interlaced (Adam7) PNG"A PNG this decoder declines. Stored intact with real dimensions; captured live below
201 a genuine Adam7-interlaced 600×400 PNG
{
  "id": 43,
  "url": "/media/p39/e07c6652e67e/original.png",
  "content_type": "image/png",
  "bytes": 302431,
  "width": 600,
  "height": 400,
  "checksum": "e07c6652e67edc474c8dc7ce5a2a96f0ef9c07567d2902a48e0ca7fd61625f50",
  "variants": {},
  "variants_supported": false,
  "variants_note": "interlaced (Adam7) PNG"
}

GET /v1/media/config reports the authoritative variants.resizable and variants.passthrough lists, the sizes, and the engine for the install you are actually talking to. Installing a native image library later widens resizable with no API change — which is exactly why you should branch on these fields rather than on format names of your own.

SVG is refused with 415, not sanitised

There is no SVG sanitiser in this API, and adding one is not planned. An SVG served from your own origin is a script-execution context — inline <script>, on* handlers, xlink:href to javascript:, external entity references — and a sanitiser for it is a permanent arms race for a format nobody needs for product photography. So an SVG is detected and rejected:

{
  "error": "unsupported_media_type",
  "message": "SVG uploads are refused: an SVG served from this origin can execute script. Rasterise it (PNG/WebP) first, or keep it on a CDN and add it as a url-only media row."
}

Detection is by content, not extension: the sniffer skips a BOM, leading whitespace, an XML prolog and comments before looking for an <svg> element, so renaming the file changes nothing. The two supported routes are to rasterise to PNG or WebP before uploading, or to keep the SVG on a CDN and add it as a url-only row — which this install then never serves from its own origin. GET /v1/media/config lists SVG under rejected_content_types with exactly this reason, so an uploader can warn the user before spending the upload.

The MIME type comes from the magic bytes

Both the declared Content-Type and the filename extension are caller-controlled, so neither is ever trusted. The recorded content_type and the stored extension come from the leading bytes of the file. Here is a file named actually-jpeg.png that contains JPEG bytes:

201 uploaded as .png, stored as image/jpeg
curl -X POST .../v1/products/ATOMOS-NINJA-V/media -F "file=@actually-jpeg.png"

{
  "sku": "ATOMOS-NINJA-V",
  "deduped": false,
  "item": {
    "id": 43,
    "url": "/media/p30/7e9af1d61596/original.jpg",
    "storage_key": "p30/7e9af1d61596/original.jpg",
    "content_type": "image/jpeg",
    "bytes": 22483,
    "width": 900,
    "height": 600,
    "checksum": "7e9af1d61596a812fd1529eb70689718916cdfdbf86d728a084b22924781cc15",
    "variants": {},
    "variants_supported": false,
    "variants_note": "no built-in decoder for image/jpeg — variants are generated for image/png only",
    "created_at": "2026-07-30 02:20:31"
  }
}

The .png in the request produced original.jpg, content_type: "image/jpeg" and — because it is a JPEG — no variants. The practical consequences: a mislabelled but genuine image is stored correctly rather than rejected; an HTML or script payload wearing a .png name is a 415 rather than a file served from your origin; and you should read content_type back off the response instead of assuming the type you sent.

Content-addressed keys, immutable caching, ETag/304

Every stored object's key is p<productId>/<first 12 hex of the SHA-256>/original.<ext>, with variants alongside it as thumb.png, small.png and medium.png. Because the path contains a hash of the content, the bytes under a key can never change — which is what licenses an aggressive cache policy on the public serving route.

200 GET /media/p38/d11b3aae2b72/original.png — response headers
HTTP/1.1 200 OK
content-type: image/png
cache-control: public, max-age=31536000, immutable
etag: "p38d11b3aae2b72originalpng-1deaa"
last-modified: Thu, 30 Jul 2026 02:19:29 GMT
x-content-type-options: nosniff
accept-ranges: none
content-length: 122538
304 the same request with If-None-Match
curl -H 'If-None-Match: "p38d11b3aae2b72originalpng-1deaa"' \
     .../media/p38/d11b3aae2b72/original.png

HTTP/1.1 304 Not Modified
cache-control: public, max-age=31536000, immutable
etag: "p38d11b3aae2b72originalpng-1deaa"
last-modified: Thu, 30 Jul 2026 02:19:29 GMT

GET /media/* is public — deliberately outside /v1. Everything under /v1 needs an API key or a session, and an <img> tag can send neither. Serving media outside /v1 keeps uploaded images loadable by a storefront exactly like the URL rows they replace. Treat uploaded media as public.

If-None-Match is honoured (including a weakened W/ tag and a comma-separated list), and If-Modified-Since is honoured when no If-None-Match is present. A key that does not embed a hash — a row migrated in from elsewhere — is served public, max-age=300, must-revalidate instead, with an mtime-based ETag. cache.immutable_keys in the config tells you which regime this install's own keys get.

PATCH/v1/products/:sku/media/:idwrite

Edits one row's metadata. Send at least one of alt, position or kind — an empty body is a 400. Works on uploaded and url-only rows alike; it touches the row, never the bytes.

FieldNotes
altUp to 1000 characters, or null to clear
positionMoves this row to that 0-based index; the rest close up around it and the gallery is renumbered dense. Clamped to the list, so a large number means "last" rather than an error
kind1–32 characters
curl -s -X PATCH https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media/40 \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"alt": "Motor mount detail, 0.8 mod gear ring engaged", "position": 0, "kind": "image"}'
200 response — the row, plus the renumbered gallery
{
  "sku": "NLAV-DOCS-1",
  "item": {
    "id": 40,
    "url": "/media/p38/76b6f4ccd4bd/original.png",
    "kind": "image",
    "position": 0,
    "alt": "Motor mount detail, 0.8 mod gear ring engaged",
    "source": "upload",
    "storage_key": "p38/76b6f4ccd4bd/original.png",
    "content_type": "image/png",
    "bytes": 2987,
    "width": 48,
    "height": 32,
    "checksum": "76b6f4ccd4bda153b4bea2fb77ce3ae9f625b7fad665504d6c1f5f863e340211",
    "variants": {},
    "variants_supported": true,
    "variants_note": "original is 48x32, smaller than every variant size",
    "created_at": "2026-07-30 02:20:31"
  },
  "media": [
    // id 40 is now position 0; 39, 41, 42 follow as 1, 2, 3
  ]
}
400 404 the refusals
// 400 — nothing to do
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [ { "path": "(root)", "message": "Provide at least one of alt, position or kind" } ]
}

// 404 — the id exists, but not on this product
{ "error": "not_found", "message": "Media 1 does not belong to this product" }
PUT/v1/products/:sku/media/orderwrite

The drag-and-drop reorder. {"ids": [...]} — a bare array is also accepted — puts those rows first, in that order. Any row you leave out keeps its relative order and lands after the listed ones, so a partial list is a safe "move these to the front" rather than a data-losing replace. Up to 200 ids.

curl -s -X PUT https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media/order \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids": [42, 39]}'
200 response — 42 and 39 first, the unlisted rows after
{
  "sku": "NLAV-DOCS-1",
  "media": [
    { "id": 42, "position": 0, "content_type": "image/jpeg" /* … */ },
    { "id": 39, "position": 1, "content_type": "image/png"  /* … */ },
    { "id": 40, "position": 2, "content_type": "image/png"  /* … */ },
    { "id": 41, "position": 3, "content_type": "image/png"  /* … */ }
  ]
}
400 the refusals — both validated before anything moves
{ "error": "bad_request", "message": "Media 1, 2 does not belong to this product" }

{ "error": "bad_request", "message": "The ids list contains duplicates" }

The reorder runs in one transaction, so a rejected list leaves the gallery exactly as it was. The response is the gallery only — there is no item to single out.

DELETE/v1/products/:sku/media/:idwrite

Removes the row and its bytes — the original and every generated variant — then renumbers the gallery. Answers 200 with the keys it actually removed, so a caller can see what was reclaimed. For a url-only row removed_keys is []: there were never any bytes here to delete.

curl -s -X DELETE https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media/39 \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response — original plus all three variants gone
{
  "sku": "NLAV-DOCS-1",
  "deleted": true,
  "removed_keys": [
    "p38/d11b3aae2b72/original.png",
    "p38/d11b3aae2b72/thumb.png",
    "p38/d11b3aae2b72/small.png",
    "p38/d11b3aae2b72/medium.png"
  ],
  "media": [
    // the remaining rows, renumbered 0..n-1
  ]
}
404 not this product's
{ "error": "not_found", "message": "Media 9999 does not belong to this product" }

No stored file is ever deleted while another row still references it. Because keys are content-addressed, two rows can legitimately share an asset — dedupe, or a url-list round trip that reattached it. Delete checks every other row's original and variant keys first, so shared bytes survive and removed_keys tells you what genuinely went. It can therefore be shorter than the asset's full key list, or empty.

POST/v1/media/gcowner

Deletes stored files that no media row references any more. You need this because rows can disappear without passing through DELETE: deleting a product cascades its rows away, the inheritance "clear own media" token drops a child's rows, and a PUT that shortens the URL list replaces them. The install already sweeps automatically — on start-up and shortly after the request shapes that can cascade, coalesced and rate-limited — so this endpoint is the explicit, immediate form, not the only one.

FieldNotes
dry_runtrue reports exactly what it would delete and deletes nothing. Always worth running first
min_age_msLeave unreferenced files younger than this alone. Defaults to 0 — an operator asking for a sweep means now. A cron against a busy install should pass a few minutes so it can never race an upload sitting between its write and its INSERT. Max 86 400 000 (24 h)
# look first
curl -s -X POST https://simplypim.co.uk/v1/media/gc \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dry_run": true}'

# then sweep — an empty body is fine, and means dry_run:false, min_age_ms:0
curl -s -X POST https://simplypim.co.uk/v1/media/gc -H "X-API-Key: $SIMPLYPIM_KEY"
200 dry_run after a product delete orphaned one file
{
  "driver": "local",
  "scanned": 13,
  "deleted": [ "p30/66413a641b55/original.jpg" ],
  "bytes_reclaimed": 52895,
  "skipped_recent": 0,
  "truncated": false,
  "min_age_ms": 0,
  "trigger": "api",
  "duration_ms": 1,
  "directory": "/…/media",
  "note": "1 unreferenced file(s) found; nothing deleted (dry_run)"
}
200 the real sweep, then a clean install
{
  "driver": "local",
  "scanned": 13,
  "deleted": [ "p30/66413a641b55/original.jpg" ],
  "bytes_reclaimed": 52895,
  "skipped_recent": 0,
  "truncated": false,
  "min_age_ms": 0,
  "trigger": "api",
  "duration_ms": 1,
  "directory": "/…/media",
  "note": "1 unreferenced file(s) deleted from /…/media"
}

// nothing left to reclaim
{
  "driver": "local",
  "scanned": 8,
  "deleted": [],
  "bytes_reclaimed": 0,
  "skipped_recent": 0,
  "truncated": false,
  "min_age_ms": 300000,
  "trigger": "api",
  "duration_ms": 0,
  "directory": "/…/media",
  "note": "0 unreferenced file(s) deleted from /…/media"
}
FieldMeaning
deletedThe keys removed — or, under dry_run, the keys that would be
scannedFiles walked in the media directory
skipped_recentUnreferenced files left alone for being younger than min_age_ms
truncatedtrue when the scan hit its 50 000-file ceiling. What it did scan is still safe to act on; it just may not have seen everything
trigger"api" for this endpoint. Automatic sweeps record what caused them ("startup", "media-upload", a request shape)
directoryThe local media root, or null on the s3 driver

Sweeping is implemented for the local driver only. On s3 the call is a safe no-op that returns deleted: [] and a note saying so — running a delete-everything-unreferenced pass against a shared bucket is not something to do implicitly. Use bucket lifecycle rules there. Check driver in the response, not just the status code.

A file is deleted only when no row references it, originals and variants alike; in-flight uploads and .tmp- staging files are never touched. The consequence of the automatic sweep being time-based rather than transactional is that orphaned bytes outlive their row by minutes, never indefinitely.

GET/v1/media/configread

Everything about this install's DAM, in one call: the driver, the size cap, what is accepted, what is refused and why, which formats can be resized, the cache policy and the current counts. Read this instead of hard-coding the numbers on this page. The cap is an environment variable, the variant capability is a build property, and both can differ on the install you are pointed at.

curl -s https://simplypim.co.uk/v1/media/config -H "X-API-Key: $SIMPLYPIM_KEY"
200 response — a stock zero-config install
{
  "driver": "local",
  "max_upload_bytes": 15728640,
  "allowed_content_types": ["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif"],
  "rejected_content_types": [
    {
      "content_type": "image/svg+xml",
      "reason": "refused, not sanitised: an SVG served from this origin is a script execution context"
    }
  ],
  "variants": {
    "engine": "builtin-png (node:zlib, no native dependency)",
    "resizable": ["image/png"],
    "passthrough": ["image/jpeg", "image/webp", "image/avif", "image/gif"],
    "sizes": { "thumb": 96, "small": 320, "medium": 800 },
    "note": "Variants are generated for PNG only. Other formats are stored unchanged with variants_supported=false — no variant is ever reported that was not produced."
  },
  "variants_supported": true,
  "local": {
    "directory": "/…/media",
    "public_base_url": null,
    "serve_route": "GET /media/*"
  },
  "cache": {
    "immutable_max_age": 31536000,
    "revalidate_max_age": 300,
    "immutable_keys": true
  },
  "accepts": [
    "multipart/form-data (field `file`, plus optional `alt`, `kind`, `position`)",
    "application/octet-stream (raw bytes; ?filename=&alt=&kind=&position=)",
    "application/json {\"filename\":…,\"alt\":…,\"content_base64\":…} (or \"data_url\")"
  ],
  "counts": { "rows": 40, "uploaded": 4, "url_only": 36, "stored_bytes": 139911 }
}
FieldWhat to do with it
max_upload_bytesCheck file size client-side against this and save the user a 15 MB round trip that ends in 413
allowed_content_typesFeed straight into an <input accept="…">. Remember the server sniffs anyway
rejected_content_typesEach entry carries a reason you can show verbatim. SVG is the only entry as shipped
variants.resizableThe formats that will actually get thumbnails on this install. passthrough is stored-but-not-resized
variants_supportedInstall-level: false would mean no format can be resized at all. Distinct from the per-row variants_supported, which is about that file's format
cache.immutable_keystrue when this install's own keys embed a content hash, i.e. its media is served immutable
acceptsThe three request shapes, self-described
countsrows split into uploaded vs url_only, plus stored_bytes actually held

Roles: this pair is owner-gated on writes only, and they differ in practice. /v1/media/gc and /v1/media/config share one rule — "media storage: owner to write" — and because GET is a read while POST is a write, the rule bites unevenly:

Callviewereditorowner
GET /v1/media/config200200200
POST /v1/media/gc403403200
POST /v1/products/:sku/media and the PATCH/PUT/DELETE above403200200

So anyone signed in can read the config — an uploader UI needs it to validate a file, and a viewer is allowed to see one. Reclaiming storage is an owner operation. The uploads themselves are ordinary catalogue writes, so an editor can do them.

// POST /v1/media/gc as editor
{ "error": "forbidden", "message": "Your role 'editor' cannot POST /v1/media/gc — 'owner' is required (media storage).", "role": "editor", "required_role": "owner" }

// POST /v1/products/NLAV-DOCS-1/media as viewer
{ "error": "forbidden", "message": "Your role 'viewer' cannot POST /v1/products/NLAV-DOCS-1/media — 'editor' is required (the catalogue).", "role": "viewer", "required_role": "editor" }

Roles apply to session callers and to user-minted spim_… keys (a key can never exceed its owner's role). The platform's built-in machine key is the install itself, not a user, and is role-free — which is why every example above works with it.

Advanced find

GET /v1/products is also the query surface. Every filter below is AND-ed, they all compose, and the answer is the same {count, limit, offset, results} envelope the plain list returns — so a saved query and a plain page are the same call.

Two conventions run through the whole set. List params accept a comma-separated value, a repeated key, or both at once — ?brand=Sony,Canon&brand=Nikon is three brands, OR-ed. Scalar params take the last one sent if you repeat them. A blank value (?brand=) is treated as absent rather than as "match nothing".

QueryFormNotes
brandlistCase-insensitive exact match, up to 50 values
statuslistAny statuses.code. Omitted, every status is returned — this is the internal surface, not the storefront. An undefined code is a 400 that lists the valid ones
conditionlistAny conditions.code, same 400 behaviour
categoryscalarSlug or id. Matches the category plus its direct children, across every membership
category_idslistUp to 200 ids, any membership. Unknown ids are dropped rather than refused
include_descendantsbooleanExpands each category_ids entry to itself plus every descendant, recursively — not just one level, unlike category
family / family_idscalarFamily code / id. Independent of each other; both are applied if both are sent
master / master_idscalarA master's SKU or id: returns the master and all its lines. An unresolvable ref is a 400. master wins when both are sent
completeness_gte / completeness_ltescalar0–100 inclusive, fractions allowed
stockscalarin (free > 0) or out (free ≤ 0). Anything else is a 400
stock_ltscalarFree stock below this integer — the reorder query. Composes with stock
location_idscalarScopes stock / stock_lt to one location. On its own it does nothing
price_listscalarWhich line the price filters and sort=price read. Defaults to web-uk
price_gte / price_ltescalarMINOR unitsprice_lte=25000 is £250.00. Deal-aware: compares the amount payable today, so a product on an active deal is found at its deal price. A product with no row on the list is excluded
deal_activebooleantrue = only products whose deal window is open today; false = only those without one
qscalarUp to 200 characters. Full-text match with the same term expansion /v1/search uses, unioned with a substring match on SKU, name and brand
created_after / created_beforescalarISO date or date-time (see below)
updated_after / updated_beforescalarSame — the "what changed since my last sync" pair
sortscalarname, sku, updated, completeness or price. Anything else is a 400
orderscalarasc (default) or desc
limit / offsetscalar1–200, default 20 / ≥ 0, default 0. Out of range is a 400, not a silent clamp
attr.<code>eitherPer-attribute predicates — below

Dates take YYYY-MM-DD or a full stamp (2026-07-01T09:30:00Z, 2026-07-01 09:30). A bare date is inclusive at both ends: as an _after bound it means 00:00:00 that day, as a _before bound 23:59:59. An unparseable value is a 400 naming the parameter. Timestamps are compared as stored UTC wall-clock, so an offset you send is matched literally rather than converted.

sort=price puts unpriced products last — in both directions, so order=desc does not surface your gaps at the top. Ask for them with price_lte omitted and completeness_lte instead, or read completeness.missing.

GET/v1/productsread

Sony or Canon, live, E-mount, under a kilo, at least 80% complete, in stock, dearest first — one call.

curl -s -G https://simplypim.co.uk/v1/products \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  --data-urlencode "brand=Sony,Canon" \
  --data-urlencode "status=live" \
  --data-urlencode "attr.mount=Sony E" \
  --data-urlencode "attr.weight_g__lt=1000" \
  --data-urlencode "completeness_gte=80" \
  --data-urlencode "stock=in" \
  --data-urlencode "sort=price" \
  --data-urlencode "order=desc" \
  --data-urlencode "limit=2"
200 response (trimmed)
{
  "count": 3,
  "limit": 2,
  "offset": 0,
  "results": [
    {
      "id": 3,
      "sku": "SONY-A7SIII",
      "name": "Sony A7S III Full-Frame Mirrorless Camera",
      "brand": "Sony",
      "status": "live",
      "status_meta": { "code": "live", "label": "Live", "color": "#16a34a", "visible_public": true },
      "condition": "new",
      "condition_meta": { "code": "new", "label": "New", "color": "#16a34a", "sku_token": "N" },
      "master": null,
      "completeness": 100,
      "image": "https://placehold.co/400x300?text=SONY-A7SIII",
      "price": 3499,
      "deal_price": null,
      "currency": "GBP",
      "free_stock": 11
    },
    {
      "id": 13,
      "sku": "SONY-FE2470GM2",
      "name": "Sony FE 24-70mm f/2.8 GM II Lens",
      "brand": "Sony",
      "status": "live",
      "status_meta": { "code": "live", "label": "Live", "color": "#16a34a", "visible_public": true },
      "condition": "new",
      "condition_meta": { "code": "new", "label": "New", "color": "#16a34a", "sku_token": "N" },
      "master": null,
      "completeness": 100,
      "image": "https://placehold.co/400x300?text=SONY-FE2470GM2",
      "price": 1999,
      "deal_price": null,
      "currency": "GBP",
      "free_stock": 14
    }
  ]
}

count is the size of the whole filtered set, independent of paging, and is always present. Unrecognised query parameters are ignored — the one exception is a key beginning attr., which must resolve or the call is a 400.

Attribute predicates

Any attribute becomes a filter by prefixing its code with attr.. Add an operator suffix for anything other than equality.

SuffixMeansTypesSeveral values
(none) / __eqEqualsallOR
__neqDoes not equalallExcludes any match
__gtGreater thannumber, measureLast wins
__ltLess thannumber, measureLast wins
__gteAt leastnumber, measureLast wins
__lteAt mostnumber, measureLast wins
__containsSubstring — or whole-tag membership on tagstext, longtext, select, tagsOR
__emptyNo value stored (or blank)allvalue ignored
__nemptySome value storedallvalue ignored
  • Bare is equality. attr.color=Black is attr.color__eq=Black. Text, select and longtext compare case- and whitespace-insensitively; number/measure compare numerically (a non-numeric value is a 400).
  • A repeated key is OR. attr.color=black&attr.color=silver and attr.color=black,silver are the same query: either colour matches. Different codes are still AND-ed.
  • tags is membership, not substring. attr.uses=vlog matches the product whose uses list contains vlog__contains does exactly the same thing on a tags field, so there is no way to match half a tag.
  • __empty / __nempty ignore their value entirely. attr.mount__empty, attr.mount__empty=1 and attr.mount__empty=false all mean "no mount stored". To ask the opposite use __nempty__empty=0 does not invert.
  • Negation includes the unset. __neq and __empty both match a product carrying no row for that attribute at all. Pair with __nempty when you need a stored value.
Predicates that compose
# E-mount, not Super 35, at least 24 months' warranty, box dimensions recorded
attr.mount=Sony E&attr.sensor_size__neq=Super 35&attr.warranty_months__gte=24&attr.box_dimensions_mm__nempty

# either card format, and a weight on record
attr.media_type=CFexpress Type A,CFexpress Type B&attr.weight_g__nempty

# everything still missing a commodity code — the compliance gap list
attr.commodity_code__empty
400 unknown attribute, and unknown operator
{ "error": "bad_request", "message": "Unknown attribute 'nope' in filter 'attr.nope'" }

{
  "error": "bad_request",
  "message": "Unknown operator '__foo' on 'attr.mount__foo' — use one of: neq, gt, lt, gte, lte, contains, empty, nempty"
}

Attribute predicates match a product's own values only. A child line that inherits sensor_size from its master is not returned by ?attr.sensor_size=Super+35, even though its payload shows that value. Filtering on resolved values is a known follow-up; today, filter the master and read its children[].

Saved views & preferences

A saved view is a named filter set — whatever query the user built, plus the sort and the columns they chose. The server stores it verbatim and never validates it against the filter vocabulary, so a view saved today still loads after the vocabulary grows.

These routes and /v1/me/prefs are session-authed, like key management: they belong to the signed-in user, so they need the pim_session cookie. An X-API-Key — even the operator's full-access key — gets 401 unauthorized, whatever its scopes.

POST/v1/saved-viewssession
FieldNotes
name required1–120 characters, trimmed
filtersAny JSON object — the query you want replayed. Defaults to {}
sortObject, by convention {sort, order}. null clears it
columnsUp to 200 strings. null clears it
positionInteger ≥ 0; defaults to the end of your list
curl -s -X POST https://simplypim.co.uk/v1/saved-views \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Sony, cheap, in stock",
        "filters": {"brand": ["Sony"], "stock": "in", "price_lte": 250000, "attr.mount": "Sony E"},
        "sort": {"sort": "price", "order": "asc"},
        "columns": ["sku", "name", "price", "free_stock"]
      }'
201 response
{
  "id": 1,
  "name": "Sony, cheap, in stock",
  "filters": {
    "brand": ["Sony"],
    "stock": "in",
    "price_lte": 250000,
    "attr.mount": "Sony E"
  },
  "sort": { "sort": "price", "order": "asc" },
  "columns": ["sku", "name", "price", "free_stock"],
  "position": 0,
  "created_at": "2026-07-30 01:10:14"
}

GET /v1/saved-views returns { "saved_views": [...] } in position order; GET, PATCH and DELETE /v1/saved-views/:id handle one (PATCH takes the same fields, all optional; DELETE answers 204). Views are per-user — someone else's id is a 404, never a 403, so ids cannot be probed. You may keep 200, and each of filters/sort/columns may serialise to 32 KB.

PUT/v1/me/prefs/:keysession

A per-user key/value scratchpad for UI state — chosen columns, row density, the last tab. Send {"value": …} and the wrapper is unwrapped for you; send anything else and the whole body is stored as-is. Values may be any JSON up to 32 KB.

Keys must start with a letter or digit and may then contain letters, digits, ., _, : and -, up to 120 characters.

curl -s -X PUT https://simplypim.co.uk/v1/me/prefs/products.columns \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"value": ["sku", "name", "brand", "price", "free_stock"]}'

# a bare value works too
curl -s -X PUT https://simplypim.co.uk/v1/me/prefs/products.density \
  -b cookies.txt -H "Content-Type: application/json" -d '"compact"'
200 response
{
  "key": "products.columns",
  "value": ["sku", "name", "brand", "price", "free_stock"],
  "updated_at": "2026-07-30 01:10:25"
}
200 GET /v1/me/prefs
{
  "prefs": [
    { "key": "products.columns", "value": ["sku", "name", "brand", "price", "free_stock"], "updated_at": "2026-07-30 01:10:25" },
    { "key": "products.density", "value": "compact", "updated_at": "2026-07-30 01:10:25" }
  ]
}

PUT is an upsert and answers 200, not 201. GET /v1/me/prefs/:key is 404 for a key you never set, but DELETE is idempotent — it answers 204 either way. A malformed key is a 400 on every verb.

CSV export

The same filters, streamed as a spreadsheet. Point a feed, a buyer or Excel straight at it — it is one request with no paging.

GET/v1/products/export.csvread

Takes every parameter from Advanced find, identically — plus columns, and a limit that goes up to 50,000 here instead of 200.

columns= tokenGives you
sku name brand descriptionThe identity fields. brand and description resolve through the master
id status condition completenessStatus and condition are the plain codes; completeness is 0 when unscored
family category categoriesThe family label, the primary category name, and all membership names joined with |
price deal_price currency free_stockThe web-uk projection, major units. Empty cell when there is no price; free_stock is 0
created_at updated_at imageTimestamps and the primary image URL
attr.<code>One attribute. tags come back joined with , ; a child line exports the resolved value
price.<list>That price line's amount payable today (deal applied), major units
stock.<location>Free stock at that location code

Omit columns and you get these nine, in this order: sku, name, brand, status, condition, completeness, price, currency, free_stock. Up to 100 columns per export.

curl -s -G https://simplypim.co.uk/v1/products/export.csv \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  --data-urlencode "brand=Sony" \
  --data-urlencode "columns=sku,name,attr.mount,price.web-uk,stock.NEW-LON,free_stock" \
  -o sony.csv
200 response
content-type: text/csv; charset=utf-8
content-disposition: attachment; filename="products-2026-07-30.csv"
x-total-count: 3

sku,name,attr.mount,price.web-uk,stock.NEW-LON,free_stock
SONY-A7SIII,Sony A7S III Full-Frame Mirrorless Camera,Sony E,3499,8,11
SONY-BPU100,Sony BP-U100 Battery Pack,Sony BP-U,449,7,7
SONY-FE2470GM2,Sony FE 24-70mm f/2.8 GM II Lens,Sony E,1999,10,14

The header row repeats your tokens verbatim, so a column can read attr.mount. X-Total-Count is the number of data rows, excluding the header. Rows end CRLF, cells are quoted only when they need it, and an empty result still returns its header row.

400 unknown column
{
  "error": "bad_request",
  "message": "Unknown column 'nope' — use a core field (id, sku, name, brand, status, condition, description, family, category, categories, created_at, updated_at, completeness, price, deal_price, currency, free_stock, image) or attr.<code>, price.<list>, stock.<location>"
}

Every column is validated before a single byte is written, so a typo is a clean 400 rather than a truncated file.

Categories

Categories are a tree, and a product can sit in as many of them as you like — exactly one membership is primary (that is the one mirrored in the product's category field and used by SKU conventions and price anchors).

GET/v1/categoriesread

Flat list plus a nested tree with per-node product counts (product_count = filed directly here, product_count_deep = including descendants, distinct products).

curl -s https://simplypim.co.uk/v1/categories -H "X-API-Key: $SIMPLYPIM_KEY"
200 response (trimmed)
{
  "categories": [
    { "id": 12, "name": "Apparel", "slug": "apparel", "parent_id": null, "parent_slug": null, "path": "Apparel", "product_count": 7 }
    // … one entry per category
  ],
  "tree": [
    {
      "id": 1, "name": "Cameras", "slug": "cameras", "parent_id": null,
      "path": "Cameras", "product_count": 0, "product_count_deep": 11,
      "children": [
        { "id": 2, "name": "Cine Cameras", "slug": "cine-cameras", "parent_id": 1,
          "path": "Cameras / Cine Cameras", "product_count": 6, "product_count_deep": 6, "children": [] }
      ]
    }
  ]
}
POST/v1/categorieswrite

name is required; slug is derived (and de-duplicated) when omitted. Nest with parent_id or parent (a slug).

curl -s -X POST https://simplypim.co.uk/v1/categories \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Cage Accessories", "parent": "camera-support"}'
201 response
{
  "id": 13,
  "name": "Cage Accessories",
  "slug": "cage-accessories",
  "parent_id": 9,
  "parent_slug": "camera-support",
  "path": "Camera Support & Rigging / Cage Accessories",
  "product_count": 0
}

PATCH /v1/categories/:id renames, re-slugs or re-parents (cycles are refused with 400); :id accepts a slug as well as a numeric id. DELETE /v1/categories/:id?strategy=detach or ?reassign_to=<id> says what happens to the products underneath — deleting a category that still holds products without a strategy is a 400. GET /v1/categories/:id/products lists what is filed there.

Attributes & families

Attributes are your own fields. Seven types: text, number, boolean, select, measure (value + unit), longtext and tags (a list of strings). A family groups the attributes a kind of product should have and marks some required, which is what completeness scores against.

A family is not the whole story. Attribute sets add fields by category and inherit down the tree, so what a product is actually asked for is family plus sets. Completeness and enrichment both work from that union — read it from the schema endpoints rather than from a family.

GET/v1/attributesread
curl -s https://simplypim.co.uk/v1/attributes -H "X-API-Key: $SIMPLYPIM_KEY"
200 response (trimmed)
{
  "attributes": [
    {
      "id": 29, "code": "warranty_months", "label": "Warranty", "type": "number",
      "unit": null, "options": null, "group": "compliance",
      "quick_filter": false,
      "usage": { "products": 27, "families": 7, "sets": 0 }
    },
    {
      "id": 30, "code": "show_on_web", "label": "Show on Web", "type": "boolean",
      "unit": null, "options": null, "group": "flags",
      "quick_filter": true,
      "usage": { "products": 22, "families": 0, "sets": 0 }
    }
  ]
}

Create fields with POST /v1/attributes ({code, label, type, unit?, options?, group?, quick_filter?}code is lower_snake_case). PATCH /v1/attributes/:code edits the label, unit, options, group and quick_filter; code and type are immutable. DELETE /v1/attributes/:code removes the field and its values everywhere — check usage first, which counts the products carrying a value and the families and attribute sets asking for it. GET /v1/attribute-groups lists the groups in use.

PUT/v1/products/:sku/attributeswrite

Upserts values. Send a {code: value} map (easiest), or {"attributes": [{"code": …, "value": …}]}. A null or empty value clears the field. measure accepts a number or {value, unit}; tags accepts an array or a comma-separated string; unknown codes are 404 not_found.

curl -s -X PUT https://simplypim.co.uk/v1/products/NLAV-DOCS-1/attributes \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"color": "Black", "max_load_kg": {"value": 8, "unit": "kg"}, "weather_sealed": true}'
200 response (trimmed)
{
  "sku": "NLAV-DOCS-1",
  "applied": [
    { "code": "color", "value": "black" },
    { "code": "max_load_kg", "value": 8 },
    { "code": "weather_sealed", "value": true }
  ],
  "cleared": [],
  "attributes": [ /* the product's full attribute list, typed */ ]
}

Families: GET /v1/families, POST /v1/families ({code, label, attributes: [{code, required}]}) and PUT /v1/families/:code/attributes to replace a family's schema.

Attribute sets

A family answers "what kind of thing is this?" — one per product. An attribute set answers a different question: "what else does anything filed here need?" A set is a named bundle of fields assigned to one or more categories, and sets are inherited down the category tree, so a set on cameras also governs cameras / cine-cameras.

The two compose rather than compete. A product is asked for its family's attributes plus every set reaching it, deduped — which is why the schema endpoints below, not the family, are what a product form should be built from.

FamilyAttribute set
How many per productOneAny number
Attached toThe productA category — and inherited by its descendants
Answers"What kind of thing is this?""What does anything filed here need?"
Good forCamera, Lens, BatteryShipping & compliance, Rental operations, Regional labelling
GET/v1/attribute-setsread

Every set, each with its members (in position order, fully typed like any attribute) and the categories it is assigned to.

curl -s "https://simplypim.co.uk/v1/attribute-sets" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (one set, members trimmed)
{
  "attribute_sets": [
    {
      "id": 1,
      "code": "lens-specs",
      "label": "Lens specs",
      "description": "The numbers a filmmaker checks before buying glass. Assigned to Lenses, so every lens is asked for them.",
      "attributes": [
        {
          "id": 13, "code": "focal_length_mm", "label": "Focal Length", "type": "text",
          "unit": null, "options": null, "group": "technical",
          "quick_filter": false, "required": true, "position": 0
        },
        {
          "id": 2, "code": "mount", "label": "Mount", "type": "select",
          "unit": null,
          "options": ["Sony E", "Canon RF", "Canon EF", "L-Mount", "PL", "MFT"],
          "group": "technical",
          "quick_filter": false, "required": true, "position": 1
        },
        {
          "id": 15, "code": "filter_thread_mm", "label": "Filter Thread", "type": "measure",
          "unit": "mm", "options": null, "group": "technical",
          "quick_filter": false, "required": true, "position": 3
        }
      ],
      "categories": [
        { "id": 4, "name": "Lenses", "slug": "lenses", "path": "Lenses" }
      ]
    }
  ]
}

GET /v1/attribute-sets/:ref returns one set. :ref is the numeric id or the code, interchangeably, on every route in this section.

POST/v1/attribute-setswrite

Creates a set. Members and category assignments can both go in the same call, so a set is usable the moment it exists.

FieldNotes
code requiredlower-kebab-case (underscores allowed), 64 characters. Immutable once created — change label instead
label requiredWhat a human sees, 120 characters
descriptionUp to 2,000 characters. Shown on the set's card in the dashboard
attributesUp to 200 members. Each is either a bare attribute code or { code, required?, position? }; array order becomes position when you omit it
categoriesUp to 200 category ids or slugs, mixed freely. category_ids is an accepted alias
curl -s -X POST "https://simplypim.co.uk/v1/attribute-sets" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "shipping-compliance",
    "label": "Shipping & compliance",
    "description": "What the courier and the customs broker need.",
    "attributes": [
      { "code": "weight_g", "required": true },
      { "code": "box_dimensions_mm", "required": true },
      "commodity_code",
      "un_code"
    ],
    "categories": ["cameras"]
  }'
201 response
{
  "id": 2,
  "code": "shipping-compliance",
  "label": "Shipping & compliance",
  "description": "What the courier and the customs broker need.",
  "attributes": [
    {
      "id": 24, "code": "weight_g", "label": "Weight", "type": "measure",
      "unit": "g", "options": null, "group": "logistics",
      "quick_filter": false, "required": true, "position": 0
    },
    {
      "id": 25, "code": "box_dimensions_mm", "label": "Box Dimensions", "type": "text",
      "unit": null, "options": null, "group": "logistics",
      "quick_filter": false, "required": true, "position": 1
    },
    {
      "id": 26, "code": "commodity_code", "label": "Commodity Code", "type": "text",
      "unit": null, "options": null, "group": "logistics",
      "quick_filter": false, "required": false, "position": 2
    },
    {
      "id": 27, "code": "un_code", "label": "UN Dangerous Goods Code", "type": "text",
      "unit": null, "options": null, "group": "logistics",
      "quick_filter": false, "required": false, "position": 3
    }
  ],
  "categories": [
    { "id": 1, "name": "Cameras", "slug": "cameras", "path": "Cameras" }
  ]
}
400 a code that is not kebab/snake case
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "code", "message": "code must be lower kebab/snake case" }
  ]
}
404 a member that is not an attribute
{ "error": "not_found", "message": "Attribute 'no_such_attr' not found" }

Members: add, remove, reorder, require

A set's member list is editable four ways. All of them answer with the whole set, so a UI never needs a follow-up read.

RouteDoes
PUT /v1/attribute-sets/:ref/attributesReplaces the member list; array order becomes position
POST /v1/attribute-sets/:ref/attributesAdds members, and re-toggles required/position on ones already there
DELETE /v1/attribute-sets/:ref/attributes/:codeRemoves one member. Answers with the set plus removed (a count)
PATCH /v1/attribute-sets/:refLabel, description, and the incremental knobs: add, remove, order, attributes

Both member bodies accept a bare array or { "attributes": [...] }, and each entry is a bare code or { code, required?, position? }.

POST/v1/attribute-sets/:ref/attributeswrite

Adds battery_wh and, in the same call, promotes the existing commodity_code member to required — adding and re-toggling are one operation.

curl -s -X POST "https://simplypim.co.uk/v1/attribute-sets/shipping-compliance/attributes" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe" \
  -H "Content-Type: application/json" \
  -d '{ "attributes": [
        { "code": "battery_wh" },
        { "code": "commodity_code", "required": true }
      ] }'
200 members afterwards (projection)
[
  { "code": "weight_g",          "required": true,  "position": 0 },
  { "code": "box_dimensions_mm", "required": true,  "position": 1 },
  { "code": "commodity_code",    "required": true,  "position": 2 },
  { "code": "un_code",           "required": false, "position": 3 },
  { "code": "battery_wh",        "required": false, "position": 4 }
]
PATCH/v1/attribute-sets/:refwrite

Drag-and-drop ordering is a single order array of member codes in their new order. Categories can be re-assigned in the same call with category_ids (replace), assign_categories or unassign_categories (incremental).

curl -s -X PATCH "https://simplypim.co.uk/v1/attribute-sets/shipping-compliance" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe" \
  -H "Content-Type: application/json" \
  -d '{ "order": ["commodity_code", "un_code", "weight_g", "box_dimensions_mm", "battery_wh"] }'
200 members afterwards (projection)
[
  { "code": "commodity_code",    "required": true,  "position": 0 },
  { "code": "un_code",           "required": false, "position": 1 },
  { "code": "weight_g",          "required": true,  "position": 2 },
  { "code": "box_dimensions_mm", "required": true,  "position": 3 },
  { "code": "battery_wh",        "required": false, "position": 4 }
]
DELETE/v1/attribute-sets/:ref/attributes/:codewrite

Removes one member and answers with the set plus a removed count.

curl -s -X DELETE \
  "https://simplypim.co.uk/v1/attribute-sets/shipping-compliance/attributes/battery_wh" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (trimmed)
{
  "id": 2,
  "code": "shipping-compliance",
  "label": "Shipping & compliance",
  "attributes": [
    { "code": "commodity_code",    "required": true,  "position": 0 },
    { "code": "un_code",           "required": false, "position": 1 },
    { "code": "weight_g",          "required": true,  "position": 2 },
    { "code": "box_dimensions_mm", "required": true,  "position": 3 }
  ],
  "removed": 1
}
DELETE/v1/attribute-sets/:refwrite

Drops the set. Members and category assignments cascade; no attribute and no product value is touched — you are deleting the grouping, not the data. The response tells you exactly what went with it.

curl -s -X DELETE "https://simplypim.co.uk/v1/attribute-sets/scratch-set" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response
{
  "id": 4,
  "code": "scratch-set",
  "deleted": true,
  "attributes_removed": 0,
  "categories_unassigned": 0
}
404 unknown set
{ "error": "not_found", "message": "Attribute set 'nope' not found" }

Assigning sets to categories

The assignment is one relationship, editable from either end — pick whichever matches the screen you are building.

From the setDoes
PUT /v1/attribute-sets/:ref/categoriesReplace the set's category list
POST /v1/attribute-sets/:ref/categoriesAssign more categories
DELETE /v1/attribute-sets/:ref/categories/:categoryUnassign one
From the categoryDoes
GET /v1/categories/:id/attribute-setsIts own sets and the ones it inherits
PUT /v1/categories/:id/attribute-setsReplace the sets this category carries
PATCH /v1/categories/:id { attribute_set_ids }The same thing while editing the category, so one call does everything

Every one of these accepts ids or slugs/codes, mixed. The category bodies also accept a bare array, { category_ids } or { categories }; the category-side body accepts { attribute_set_ids } or { attribute_sets }.

GET/v1/categories/:id/attribute-setsread

What this category carries, split into sets (assigned to it directly) and inherited (assigned to an ancestor). Each inherited entry names the category it came from and how far up it lives — depth 1 is the parent, 2 the grandparent.

curl -s "https://simplypim.co.uk/v1/categories/cine-cameras/attribute-sets" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (set members trimmed)
{
  "category": {
    "id": 2,
    "name": "Cine Cameras",
    "slug": "cine-cameras",
    "parent_id": 1,
    "parent_slug": "cameras",
    "path": "Cameras / Cine Cameras",
    "product_count": 11
  },
  "sets": [],
  "inherited": [
    {
      "set": {
        "id": 2,
        "code": "shipping-compliance",
        "label": "Shipping & compliance",
        "description": "What the courier and the customs broker need.",
        "attributes": [
          { "code": "weight_g",          "required": true,  "position": 0 },
          { "code": "box_dimensions_mm", "required": true,  "position": 1 },
          { "code": "commodity_code",    "required": true,  "position": 2 },
          { "code": "un_code",           "required": false, "position": 3 }
        ],
        "categories": [
          { "id": 1, "name": "Cameras", "slug": "cameras", "path": "Cameras" }
        ]
      },
      "from": { "id": 1, "name": "Cameras", "slug": "cameras" },
      "depth": 1
    }
  ]
}
PATCH/v1/categories/:idwrite

Assigning from the category side while editing it. The category payload gains an attribute_sets summary so the caller can confirm without a second read.

curl -s -X PATCH "https://simplypim.co.uk/v1/categories/ssds" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe" \
  -H "Content-Type: application/json" \
  -d '{ "attribute_set_ids": ["shipping-compliance"] }'
200 response
{
  "id": 7,
  "name": "Portable SSDs",
  "slug": "ssds",
  "parent_id": 5,
  "parent_slug": "media-storage",
  "path": "Media & Storage / Portable SSDs",
  "product_count": 1,
  "attribute_sets": [
    {
      "id": 2,
      "code": "shipping-compliance",
      "label": "Shipping & compliance",
      "description": "What the courier and the customs broker need."
    }
  ]
}

The effective schema — what a product form should read

This is the part that matters. A product's effective schema is the union of three things:

how it resolves
family attributes
  ∪  sets assigned to every category the product is filed under
  ∪  sets assigned to any ANCESTOR of those categories

deduped by attribute code
required = true when it is required ANYWHERE

Order is stable and meant to be rendered as-is:

  1. the family first (required members before optional, then by code);
  2. then the sets of each assigned category, nearest first — the category itself, then its parent, then its grandparent;
  3. sets within a category by code, and members within a set by position.

Every entry carries where it came from:

sourceMeans
familyThe product's family declares it
set:<code>A set on a category the product is filed under directly
inherited:<category-slug>A set on an ancestor of such a category — the slug is the ancestor that carries the set

source is the first contributor, and it is what fixes the ordering. sources[] lists every contributor, because one attribute can legitimately sit in a family and in a set at once — that is how required gets decided.

GET/v1/categories/:id/schemaread

The schema before a product exists — what a create-product form should ask for. Pass ?family=<code> once the user has picked a family and you get the whole thing in one call, family and sets together.

QueryNotes
familyA family code. Omit it and you get only the set-derived half (family is null); unknown codes are 404
curl -s "https://simplypim.co.uk/v1/categories/cine-cameras/schema?family=camera" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (attribute list abbreviated to the fields that matter here)
{
  "category": {
    "id": 2, "name": "Cine Cameras", "slug": "cine-cameras",
    "parent_id": 1, "parent_slug": "cameras",
    "path": "Cameras / Cine Cameras", "product_count": 11
  },
  "family": { "id": 1, "code": "camera", "label": "Cameras" },
  "attribute_sets": [
    {
      "code": "rental-ops",
      "label": "Rental operations",
      "category": { "id": 2, "name": "Cine Cameras", "slug": "cine-cameras" },
      "inherited": false,
      "depth": 0
    },
    {
      "code": "shipping-compliance",
      "label": "Shipping & compliance",
      "category": { "id": 1, "name": "Cameras", "slug": "cameras" },
      "inherited": true,
      "depth": 1
    }
  ],
  "attributes": [
    { "code": "max_resolution",    "required": true,  "source": "family",            "sources": ["family"] },
    { "code": "mount",             "required": true,  "source": "family",            "sources": ["family"] },
    { "code": "sensor_size",       "required": true,  "source": "family",            "sources": ["family"] },
    { "code": "weight_g",          "required": true,  "source": "family",            "sources": ["family", "inherited:cameras"] },
    { "code": "battery_wh",        "required": false, "source": "family",            "sources": ["family"] },
    { "code": "box_dimensions_mm", "required": true,  "source": "family",            "sources": ["family", "inherited:cameras"] },
    { "code": "commodity_code",    "required": true,  "source": "family",            "sources": ["family", "inherited:cameras"] },
    { "code": "in_the_box",        "required": false, "source": "family",            "sources": ["family"] },
    { "code": "key_feature",       "required": false, "source": "family",            "sources": ["family"] },
    { "code": "seo_description",   "required": false, "source": "family",            "sources": ["family"] },
    { "code": "seo_title",         "required": false, "source": "family",            "sources": ["family"] },
    { "code": "un_code",           "required": false, "source": "family",            "sources": ["family", "inherited:cameras"] },
    { "code": "warranty_months",   "required": false, "source": "family",            "sources": ["family"] },
    { "code": "weather_sealed",    "required": false, "source": "family",            "sources": ["family"] },
    { "code": "material",          "required": true,  "source": "set:rental-ops",    "sources": ["set:rental-ops"] },
    { "code": "max_load_kg",       "required": false, "source": "set:rental-ops",    "sources": ["set:rental-ops"] },
    { "code": "show_on_web",       "required": false, "source": "inherited:cameras", "sources": ["inherited:cameras"] }
  ],
  "required": [
    "max_resolution", "mount", "sensor_size", "weight_g",
    "box_dimensions_mm", "commodity_code", "material"
  ],
  "count": 17
}
one entry in full
{
  "code": "show_on_web",
  "label": "Show on Web",
  "type": "boolean",
  "unit": null,
  "options": null,
  "group": "flags",
  "required": false,
  "source": "inherited:cameras",
  "sources": ["inherited:cameras"],
  "sets": ["shipping-compliance"]
}
404 unknown family
{ "error": "not_found", "message": "Family 'nope' not found" }
GET/v1/products/:sku/schemaread

The same resolution for a product that already exists — it reads the product's own family and its own category memberships, so nothing has to be passed in. Accepts a SKU or a numeric product id.

curl -s "https://simplypim.co.uk/v1/products/CANON-C70/schema" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (attributes trimmed — identical shape to the category route)
{
  "sku": "CANON-C70",
  "family": { "id": 1, "code": "camera", "label": "Cameras" },
  "categories": [
    {
      "id": 2, "name": "Cine Cameras", "slug": "cine-cameras",
      "path": "Cameras / Cine Cameras", "primary": true, "position": 0
    }
  ],
  "attribute_sets": [
    {
      "code": "rental-ops",
      "label": "Rental operations",
      "category": { "id": 2, "name": "Cine Cameras", "slug": "cine-cameras" },
      "inherited": false,
      "depth": 0
    },
    {
      "code": "shipping-compliance",
      "label": "Shipping & compliance",
      "category": { "id": 1, "name": "Cameras", "slug": "cameras" },
      "inherited": true,
      "depth": 1
    }
  ],
  "attributes": [
    { "code": "material",    "required": true,  "source": "set:rental-ops",    "sources": ["set:rental-ops"] },
    { "code": "max_load_kg", "required": false, "source": "set:rental-ops",    "sources": ["set:rental-ops"] },
    { "code": "show_on_web", "required": false, "source": "inherited:cameras", "sources": ["inherited:cameras"] }
  ],
  "required": [
    "max_resolution", "mount", "sensor_size", "weight_g",
    "box_dimensions_mm", "commodity_code", "material"
  ],
  "count": 17
}

Completeness and enrichment both target it

The effective schema is not advisory. Two things already score and fill against it:

  • Completeness — a product's score and its missing[] are computed against the effective schema, so assigning a set to a category immediately moves the completeness of every product underneath it.
  • AI enrichment — the fields it tries to fill are the effective schema's, so a set is what makes enrichment ask for commodity_code on a camera.

Gotcha: attributes[].required on the product payload reflects the FAMILY only. It is the pre-1.2 field and it has not changed meaning, so it does not account for attribute sets. The schema endpoints are the source of truth for "is this field required?" — do not build a form from the product payload's required flags.

You can see the disagreement directly. For CANON-C70, the camera family declares box_dimensions_mm and commodity_code as required: false, but the shipping-compliance set on the ancestor category requires them — and material comes from a set the family has never heard of. Completeness agrees with the schema:

200 GET /v1/products/CANON-C70 — completeness scores against the effective schema
{
  "completeness": {
    "score": 74,
    "missing": ["material", "box_dimensions_mm", "commodity_code"]
  },
  "attributes": [
    { "code": "warranty_months", "required": false },
    { "code": "show_on_web",     "required": false },
    { "code": "weight_g",        "required": true  },
    { "code": "in_the_box",      "required": false },
    { "code": "key_feature",     "required": false },
    { "code": "max_resolution",  "required": true  },
    { "code": "mount",           "required": true  },
    { "code": "sensor_size",     "required": true  }
  ]
}

missing[] names the three set-derived required fields. The attributes[] list beside it shows only the family's flags and never mentions them. Both are correct for what they describe — just do not mistake the second for a schema.

Statuses

A product's status is a row in a table you own, not a fixed enum. Four ship — draft, live, discontinued, archived — and you can add "Pre-order", "Clearance" or "Awaiting images" without a migration or a release.

The one flag that carries behaviour is visible_public. It replaced every hardcoded comparison against 'live' in the codebase, so search and recommendations return exactly the statuses you have flagged public. Flip a status on and its products surface; flip it off and they vanish from the storefront while staying fully editable inside.

GET/v1/statusesread

Every status in display order, with the number of products on each.

curl -s https://simplypim.co.uk/v1/statuses -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "statuses": [
    { "code": "draft",        "label": "Draft",        "color": "#94a3b8", "position": 0, "visible_public": false, "is_default": true,  "protected": true,  "usage": { "products": 4 } },
    { "code": "live",         "label": "Live",         "color": "#16a34a", "position": 1, "visible_public": true,  "is_default": false, "protected": true,  "usage": { "products": 35 } },
    { "code": "discontinued", "label": "Discontinued", "color": "#f97316", "position": 2, "visible_public": false, "is_default": false, "protected": false, "usage": { "products": 0 } },
    { "code": "archived",     "label": "Archived",     "color": "#64748b", "position": 3, "visible_public": false, "is_default": false, "protected": true,  "usage": { "products": 0 } }
  ]
}
FieldMeaning
codeWhat every product stores. Immutable — renaming would orphan them, so change label instead
label / colorDisplay only. color is a hex triplet/quad such as #16a34a
positionDisplay order, 0–9999
visible_publicThe behaviour flag. true = products with this status appear in search, recommendations and any storefront read
is_defaultThe status a product created without one gets. Exactly one row carries it
protectedOurs, not yours: the row cannot be deleted (relabel and recolour freely). Set by the system and rejected on write
usage.productsHow many products sit on it — check before deleting. Present on the list only, not on a single read or a write response

GET /v1/statuses/:code returns one row for a picker refresh, without usage.

POST/v1/statuseswrite

code and label are required; color, visible_public (default false) and position (default: the end) are optional. Codes are lower kebab/snake case.

curl -s -X POST https://simplypim.co.uk/v1/statuses \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "clearance", "label": "Clearance", "color": "#dc2626", "visible_public": true}'
201 response
{
  "code": "clearance",
  "label": "Clearance",
  "color": "#dc2626",
  "position": 4,
  "visible_public": true,
  "is_default": false,
  "protected": false
}

PATCH /v1/statuses/:code changes label, color, position, visible_public or is_default (setting it moves the default off whichever row held it). Sending code or protected is a 400; sending nothing at all is a 400 too.

DELETE/v1/statuses/:codewrite

A status in use cannot simply vanish — the products on it would have nowhere to be. Pass ?reassign_to=<code> and both moves happen in one transaction.

curl -s -X DELETE "https://simplypim.co.uk/v1/statuses/clearance?reassign_to=live" \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "code": "clearance",
  "deleted": true,
  "products_reassigned": 1,
  "reassigned_to": "live"
}

Deleting an unused status needs no reassign_to and reports products_reassigned: 0 with reassigned_to: null. Omitting it while products remain is a 400 naming the count and the valid targets, and a protected row is refused outright.

Conditions

Conditions work exactly like statuses — same CRUD, same protected rule, same delete-with-reassign — with two differences: there is no is_default (a product with no condition is new), and each row carries a sku_token.

Four ship: new, used, demo (labelled "Ex-Demo") and showroom. Add open-box, b-stock or ex-rental and it is first class everywhere at once — on products, on individual units, as a child line, and in the units_summary breakdown.

GET/v1/conditionsread
curl -s https://simplypim.co.uk/v1/conditions -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "conditions": [
    { "code": "new",      "label": "New",      "color": "#16a34a", "position": 0, "sku_token": "N", "protected": true,  "usage": { "products": 32, "units": 5 } },
    { "code": "used",     "label": "Used",     "color": "#0ea5e9", "position": 1, "sku_token": "U", "protected": true,  "usage": { "products": 4,  "units": 17 } },
    { "code": "demo",     "label": "Ex-Demo",  "color": "#a855f7", "position": 2, "sku_token": "D", "protected": true,  "usage": { "products": 2,  "units": 3 } },
    { "code": "showroom", "label": "Showroom", "color": "#f59e0b", "position": 3, "sku_token": "S", "protected": false, "usage": { "products": 1,  "units": 2 } }
  ]
}

usage counts both the products on the condition and the serialised units, because a unit may carry a condition its product line does not — a showroom body under an otherwise used line is legitimate.

POST/v1/conditionswrite
FieldNotes
code requiredLower kebab/snake case, up to 64 characters. Immutable afterwards
label requiredUp to 120 characters
colorHex triplet/quad, e.g. #0d9488
position0–9999; defaults to the end
sku_token1–4 letters or digits, uppercased for you. What {COND} renders in a SKU convention, and the suffix a generated child line gets
curl -s -X POST https://simplypim.co.uk/v1/conditions \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "open-box", "label": "Open Box", "color": "#0d9488", "sku_token": "OB"}'
201 response
{
  "code": "open-box",
  "label": "Open Box",
  "color": "#0d9488",
  "position": 4,
  "sku_token": "OB",
  "protected": false
}
200 DELETE /v1/conditions/open-box
{
  "code": "open-box",
  "deleted": true,
  "products_reassigned": 0,
  "units_reassigned": 0,
  "reassigned_to": null
}

PATCH /v1/conditions/:code takes label, color, position and sku_token. Deleting one that is in use needs ?reassign_to=<code>, which moves the products and the units in one transaction.

400 the refusals, verbatim
# a protected row
{ "error": "bad_request", "message": "Condition 'new' is protected and cannot be deleted. Rename or recolour it instead." }

# still in use, no reassign_to
{
  "error": "bad_request",
  "message": "Condition 'showroom' is still used by 1 product(s) and 2 serialised unit(s). Pass ?reassign_to=<condition code> to move them first (valid: new, used, demo)."
}

# an undefined code on a product, unit or filter
{
  "error": "bad_request",
  "message": "Unknown condition 'mint'. Valid conditions: new, used, demo, showroom (define more at POST /v1/conditions)"
}

# trying to rename a code
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "code", "message": "'code' is immutable — every product and unit stores it. Change 'label' instead." }
  ]
}

Reading a status or condition back. Every product payload carries the plain code and an additive status_meta / condition_meta object — {code, label, color, visible_public} and {code, label, color, sku_token} — so a grid renders the right chip without a lookup per row. Both appear on the list projection and the full record. Nothing that existed before these tables changed shape.

Quick filters

A boolean attribute flagged quick_filter: true is one a merchandiser filters by constantly — "Show on web", "Clearance", "Needs photography". The flag is a hint to the UI to render it as a one-click chip rather than burying it in the attribute list; the filter itself is the ordinary predicate, ?attr.show_on_web=true.

It applies to boolean attributes only — a chip is a two-state toggle, so setting it on any other type is a 400 naming the offending code and its type. Set it at create time or with PATCH /v1/attributes/:code.

A flag attribute, as GET /v1/attributes returns it
{
  "id": 30, "code": "show_on_web", "label": "Show on Web", "type": "boolean",
  "unit": null, "options": null, "group": "flags",
  "quick_filter": true,
  "usage": { "products": 22, "families": 0, "sets": 0 }
}

Prices

Prices live on user-defined price lines. A line is either kind: "sell" (amount + cost + RRP + an optional dated deal, with the margin computed for you) or kind: "cost" (a standalone cost track — estimated, actual, retro). Amounts are in major units (£129.99) and stored as minor units, so nothing drifts.

GET/v1/price-listsread
curl -s https://simplypim.co.uk/v1/price-lists -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "price_lists": [
    { "id": 3, "code": "b2b-trade",    "label": "B2B Trade",           "currency": "GBP", "channel": "b2b",      "kind": "sell", "position": 0, "locale": "en-GB", "usage": 15 },
    { "id": 2, "code": "web-eu",       "label": "Web EU",              "currency": "EUR", "channel": "web",      "kind": "sell", "position": 0, "locale": "de-DE", "usage": 15 },
    { "id": 1, "code": "web-uk",       "label": "Web UK",              "currency": "GBP", "channel": "web",      "kind": "sell", "position": 0, "locale": "en-GB", "usage": 34 },
    { "id": 4, "code": "web-us",       "label": "Web US",              "currency": "USD", "channel": "web",      "kind": "sell", "position": 0, "locale": "en-US", "usage": 14 },
    { "id": 5, "code": "supplier-eur", "label": "Supplier Cost (EUR)", "currency": "EUR", "channel": "internal", "kind": "cost", "position": 0, "locale": "de-DE", "usage": 15 }
  ]
}

POST /v1/price-lists creates a line ({code, currency, channel?, label?, kind?, position?, locale?}) and answers 201; PATCH /v1/price-lists/:code edits the label, channel, position and locale — code, currency and kind are immutable. usage counts the products carrying a value on the line, and DELETE reports {code, deleted, prices_removed} — a real cascade, not an archive. Creating a line in a currency you have never used registers that currency and its rate pair for you.

PUT/v1/products/:sku/priceswrite

Upserts one or more price rows. Only the lines you send are touched.

curl -s -X PUT https://simplypim.co.uk/v1/products/NLAV-DOCS-1/prices \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prices": [{
        "price_list": "web-uk",
        "amount": 129.99,
        "cost": 74.50,
        "rrp": 149.99,
        "deal": {"amount": 109.99, "start": "2026-07-01", "end": "2026-08-31"}
      }]}'
200 response
{
  "sku": "NLAV-DOCS-E",
  "prices": [
    {
      "price_list": "web-uk",
      "label": "Web UK",
      "kind": "sell",
      "position": 0,
      "currency": "GBP",
      "channel": "web",
      "amount": 129.99,
      "cost": 74.5,
      "rrp": 149.99,
      "margin_pct": 42.7,
      "deal": { "amount": 109.99, "start": "2026-07-01", "end": "2026-08-31", "active": true },
      "effective_amount": 109.99,
      "duty_pct": null,
      "shipping": null,
      "other": null,
      "landed": null,
      "margin": { "value_minor": 3549, "pct": 32.3, "basis": "cost", "vs_list": 42.7 },
      "normalized": {
        "amount_minor": 12999,
        "cost_minor": 7450,
        "rrp_minor": 14999,
        "deal_amount_minor": 10999,
        "effective_amount_minor": 10999,
        "landed_minor": null,
        "margin_pct": 42.7,
        "margin": { "value_minor": 3549, "pct": 32.3, "basis": "cost", "vs_list": 42.7 },
        "cost_source": "line",
        "currency": "GBP",
        "rate": 1,
        "rate_mode": "base",
        "as_of": null,
        "stale": false
      }
    }
  ]
}

deal.active is computed from today's date against the window; effective_amount is what a customer pays now. Flat aliases (deal_amount, deal_start, deal_end) are accepted for spreadsheet-shaped payloads. Up to 50 lines per call. Remove one line with DELETE /v1/products/:sku/prices/:code. Note margin.pct is 32.3 while margin.vs_list is 42.7 — the deal is running, so the cash margin today is measured against £109.99, not £129.99. See below.

Landed cost & margin

Every price line carries two derived blocks: margin, the money the sale makes, and normalized, the same figures restated in your base currency so lines in different currencies are comparable. Both are additive — nothing that existed before them changed.

Landed cost, on a cost line

A kind: "cost" line can carry the three components that turn an invoice price into what the goods actually cost you on the shelf. They apply to cost lines only — sending them to a sell line is a 400.

FieldNotes
duty_pctImport duty as a percentage, 0–1000
shippingFreight in, major units. Stored as shipping_minor
otherAnything else — brokerage, handling. Stored as other_minor
landed read-onlyThe result, major units. normalized.landed_minor is the same number in the base currency
The formula
landed = round(amount × (1 + duty_pct / 100)) + shipping + other

Duty is applied to the goods value only — freight and the other costs are added afterwards, so shipping is not dutiable. landed is null when the line carries none of the three components, which is exactly how you tell a plain cost from a landed one.

200 a cost line with all three, from PUT /v1/products/SONY-FX5/prices
{
  "price_list": "supplier-eur",
  "label": "Supplier Cost (EUR)",
  "kind": "cost",
  "position": 0,
  "currency": "EUR",
  "channel": "internal",
  "amount": 4300,
  "cost": null,
  "rrp": null,
  "margin_pct": null,
  "deal": null,
  "effective_amount": 4300,
  "duty_pct": 4.7,
  "shipping": 85,
  "other": 12.5,
  "landed": 4599.6,
  "margin": null,
  "normalized": {
    "amount_minor": 367521,
    "cost_minor": null,
    "rrp_minor": null,
    "deal_amount_minor": null,
    "effective_amount_minor": 367521,
    "landed_minor": 393128,
    "margin_pct": null,
    "margin": null,
    "cost_source": null,
    "currency": "GBP",
    "rate": 1.17,
    "rate_mode": "api",
    "as_of": "2026-07-30 01:02:20",
    "stale": false
  }
}

€4300 × 1.047 = €4502.10, plus €85 and €12.50 = €4599.60 landed, which at 1.17 normalises to £3931.28. A cost line never carries a margin, cost, rrp or deal — sending those is a 400.

Margin, on a sell line

KeyMeaning
value_minorThe cash the sale makes today, minor units: effective amount − cost. Can be negative
pctThat as a percentage of the effective (deal-aware) price, to one decimal. null when there is no cost basis
basis"landed" when the cost behind the number includes duty/shipping/other, else "cost"
vs_listThe same percentage measured against the list amount. Equal to pct when no deal is running

The cost basis is chosen in one order: the line's own cost always wins; failing that, the product's first kind: "cost" line is borrowed, landed value first. normalized.cost_source tells you which happened — "line", or the code of the cost track it borrowed. Both are null when the product has neither.

200 a EUR sell line borrowing a landed EUR cost
{
  "price_list": "web-eu",
  "label": "Web EU",
  "kind": "sell",
  "currency": "EUR",
  "amount": 5029.83,
  "cost": null,
  "margin_pct": null,
  "effective_amount": 5029.83,
  "margin": { "value_minor": 43023, "pct": 8.6, "basis": "landed", "vs_list": 8.6 },
  "normalized": {
    "amount_minor": 429900,
    "cost_minor": 393128,
    "effective_amount_minor": 429900,
    "landed_minor": null,
    "margin_pct": 8.6,
    "margin": { "value_minor": 36772, "pct": 8.6, "basis": "landed", "vs_list": 8.6 },
    "cost_source": "supplier-eur",
    "currency": "GBP",
    "rate": 1.17,
    "rate_mode": "api",
    "as_of": "2026-07-30 01:02:20",
    "stale": false
  }
}

margin_pct and margin.vs_list are not always the same number. The top-level margin_pct is the pre-existing field and reads the line's own cost only — so on the line above it is null while margin.vs_list is 8.6, because the cost was borrowed from the cost track. Inside normalized the two always agree. Prefer margin.

The normalized block

KeyMeaning
amount_minor, cost_minor, rrp_minor, deal_amount_minor, effective_amount_minorThe line's figures converted to the base currency, minor units. cost_minor is the margin basis — the landed value when there is one
landed_minorCost lines only; always null on a sell line
margin / margin_pctThe margin restated in the base currency. This is the number to compare across a mixed-currency catalogue
cost_source"line", a cost line's code, or null
currencyThe base currency — what everything above is stated in, not the line's own currency
rateQuote units per 1 base unit for the line's currency. The amounts above were divided by it. Always 1 on a base-currency line
rate_modebase (identity), api, fixed, or mixed when a cross-rate's two legs disagree
as_ofWhen that rate was fetched. null for the base identity and for a fixed rate
staleThe rate is over 24 hours old, or was never fetched. Always false for fixed and for the base

normalized is present on every line, including base-currency ones (they carry the identity rate, so the shape never varies). It is null only when the install has no usable rate for that currency at all — in which case the pre-existing fields still speak the line's own currency and nothing breaks.

Settings, currencies & FX

Price a product in four currencies and the obvious question — "which of these actually makes money?" — needs one number everything can be compared in. That is the base currency, and everything below exists to keep it honest.

Settings

Install-wide configuration, deliberately tiny. These are not per-user preferences (those live at /v1/me/prefs), so a machine key may read and write them.

GET/v1/settingsread
KeyValuesDefault
base_currencyA 3-letter ISO code that is a known, enabled currency with a usable rateGBP
enrichment_depthminimal | standard | full — the default for enrichment runs that omit depthstandard
curl -s https://simplypim.co.uk/v1/settings -H "X-API-Key: $SIMPLYPIM_KEY"

# with a description per key, for a settings screen
curl -s "https://simplypim.co.uk/v1/settings?describe=1" -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "settings": {
    "base_currency": "GBP",
    "enrichment_depth": "standard",
    "search_log_days": 90,
    "search_typo_fallback": true,
    "api_key_quota_per_user": 20,
    "api_key_default_expiry_days": 0,
    "enforce_plan_limits": false,
    "product_limit": 0
  }
}
200 ?describe=1 adds fields (descriptions abridged)
{
  "settings": { "base_currency": "GBP", "enrichment_depth": "standard", "…": 0 },
  "fields": [
    {
      "key": "base_currency",
      "value": "GBP",
      "default": "GBP",
      "description": "Currency every amount is normalised to for comparison (margins across mixed-currency price lines, price suggestions, the category anchor). Changing it rebases the stored exchange-rate pairs — inverted/cross-rated immediately, then replaced by the provider on the next refresh."
    },
    {
      "key": "enrichment_depth",
      "value": "standard",
      "default": "standard",
      "description": "Default enrichment profile for POST /v1/products/:sku/enrich when the call omits `depth`: 'minimal' (description + auto-keywords), 'standard' (+ effective-schema attributes and SEO) or 'full' (+ relation hints and a price-suggestion refresh)."
    },
    {
      "key": "api_key_quota_per_user",
      "value": 20,
      "default": 20,
      "description": "How many live API keys one user may hold. POST /v1/api-keys answers 409 `key_quota_exceeded` above it; revoked and expired keys do not count."
    },
    {
      "key": "enforce_plan_limits",
      "value": false,
      "default": false,
      "description": "Enforce a product ceiling on product creation (409 `plan_limit_reached` with the limit and the current count). OFF by default so nothing breaks today. The ceiling is the active subscription's plan limit, or `product_limit` below when there is no active subscription."
    }
  ]
}
KeyDefaultDoes
base_currency"GBP"What every amount is normalised to. Changing it rebases your stored rates — see the warning below
enrichment_depth"standard"Default enrichment profile when a call omits depth
search_log_days90How long search_queries rows are kept. Pruned in the background, at most one sweep an hour — no cron job
search_typo_fallbacktrueRetry a zero-result search once with a spelling correction drawn from your own catalogue vocabulary. Never runs when the query already matched, so it can only add recall
api_key_quota_per_user20Live keys one person may hold — see plan limits
api_key_default_expiry_days0Expiry policy for newly minted keys that do not set their own. 0 means never expire. Existing keys are never changed
enforce_plan_limitsfalseTurn on the product ceiling (409 plan_limit_reached)
product_limit0The manual ceiling that gate uses when there is no active subscription. 0 means none

Read the key list from ?describe=1 rather than hard-coding it. Each field carries its own description, written for a settings screen, and the set grows as features land — the refusal messages below enumerate whatever the install actually knows.

describe takes 1/true (on) or 0/false (off); anything else is a 400. An unset key reads as its default, so a fresh install behaves like a configured one.

PATCH/v1/settingswrite

Send a flat map or {"settings": {...}} — a GET response can be posted straight back. The whole patch is validated before anything is written, so a rejected patch changes nothing. The response is always the full settings map.

curl -s -X PATCH https://simplypim.co.uk/v1/settings \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enrichment_depth": "full"}'
200 response — always the full map
{
  "settings": {
    "base_currency": "GBP",
    "enrichment_depth": "full",
    "search_log_days": 90,
    "search_typo_fallback": true,
    "api_key_quota_per_user": 20,
    "api_key_default_expiry_days": 0,
    "enforce_plan_limits": false,
    "product_limit": 0
  }
}
400 the refusals
{ "error": "bad_request", "message": "Unknown setting 'foo' — known keys: base_currency, enrichment_depth, search_log_days, search_typo_fallback, api_key_quota_per_user, api_key_default_expiry_days, enforce_plan_limits, product_limit" }

{ "error": "bad_request", "message": "A settings patch must carry at least one known key: base_currency, enrichment_depth, search_log_days, search_typo_fallback, api_key_quota_per_user, api_key_default_expiry_days, enforce_plan_limits, product_limit" }

{ "error": "bad_request", "message": "Invalid value for 'enrichment_depth': Invalid enum value. Expected 'minimal' | 'standard' | 'full', received 'deep'" }

Changing base_currency rebases your stored rates. Existing pairs are inverted or cross-rated immediately so nothing stops converting, then replaced by the provider on the next refresh. The target must already be a known, enabled currency with a usable rate — otherwise the patch is a 400 telling you which of those three is missing.

Currencies

You never create a currency by hand: create a price line in USD and the currency and its rate pair are registered for you. These two routes are for enabling, disabling and ordering what is already there.

GET/v1/currenciesread
curl -s https://simplypim.co.uk/v1/currencies -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "base": "GBP",
  "currencies": [
    { "code": "GBP", "enabled": true, "position": 0, "base": true },
    { "code": "EUR", "enabled": true, "position": 1, "base": false },
    { "code": "USD", "enabled": true, "position": 2, "base": false }
  ]
}

Disabled currencies are listed too — filter on enabled yourself. PATCH /v1/currencies/:code takes enabled and/or position and returns the bare row. The base currency cannot be disabled (400 GBP is the base currency and cannot be disabled); its position still moves. Change the base itself through PATCH /v1/settings.

Amounts are handled at two decimal places throughout. Zero-decimal currencies (JPY) and three-decimal ones (KWD) are not modelled yet, so a currency row carries no symbol or decimal count — format for display on your side.

Exchange rates

One row per non-base currency, holding a rate quoted per 1 base unit: with a GBP base, EUR 1.17 means £1 buys €1.17. There is no row for the base itself — its rate is always 1.

KeyMeaning
mode"api" — the provider's number, optionally marked up. "fixed" — a rate you typed, immune to the provider and to going stale
api_rateWhat the provider last returned. Kept and displayed even in fixed mode, so you can see what you are overriding
fixed_rateYour manual rate. null hands the pair back to the provider
markup_pctA spread on top of api_rate, −100 to 1000. Applies in api mode only
effective_rate read-onlyThe number actually used: api_rate × (1 + markup_pct/100) in api mode, or fixed_rate in fixed mode. null when there is nothing usable
fetched_atWhen we stored it, not the provider's quote date
staletrue once fetched_at is more than stale_after_hours (24) old, or was never set. Always false in fixed mode
sourceFree text — "fallback", "frankfurter (2026-07-29)", or a · rebased note after a base change

There is no age field: derive it from fetched_at if you want to show "3 days old". A stale rate is never an error and is never dropped — the last known number keeps serving, and a background refresh is attempted opportunistically.

GET/v1/exchange-ratesread
curl -s https://simplypim.co.uk/v1/exchange-rates -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "base": "GBP",
  "provider": "frankfurter",
  "stale_after_hours": 24,
  "currencies": [
    { "code": "GBP", "enabled": true, "position": 0, "base": true },
    { "code": "EUR", "enabled": true, "position": 1, "base": false },
    { "code": "USD", "enabled": true, "position": 2, "base": false }
  ],
  "rates": [
    {
      "base": "GBP", "quote": "EUR", "mode": "api",
      "fixed_rate": null, "api_rate": 1.17, "markup_pct": 0, "effective_rate": 1.17,
      "fetched_at": "2026-07-30 01:02:20", "stale": false, "source": "fallback"
    },
    {
      "base": "GBP", "quote": "USD", "mode": "api",
      "fixed_rate": null, "api_rate": 1.27, "markup_pct": 0, "effective_rate": 1.27,
      "fetched_at": "2026-07-30 01:02:20", "stale": false, "source": "fallback"
    }
  ]
}

Only enabled quote currencies are listed. A fresh install seeds fallback rates so it converts correctly with no network at all.

PATCH/v1/exchange-rates/:quotewrite

Takes mode, fixed_rate and markup_pct; omitted fields keep their stored value. Returns the bare rate row.

# pin USD to a rate you agreed with finance
curl -s -X PATCH https://simplypim.co.uk/v1/exchange-rates/USD \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "fixed", "fixed_rate": 1.25}'

# or keep the live rate and add 2.5% of headroom
curl -s -X PATCH https://simplypim.co.uk/v1/exchange-rates/EUR \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"markup_pct": 2.5}'
200 pinned to a fixed rate
{
  "base": "GBP", "quote": "USD", "mode": "fixed",
  "fixed_rate": 1.25, "api_rate": 1.27, "markup_pct": 0, "effective_rate": 1.25,
  "fetched_at": "2026-07-30 01:02:20", "stale": false, "source": "fallback"
}
200 live rate plus a 2.5% spread
{
  "base": "GBP", "quote": "EUR", "mode": "api",
  "fixed_rate": null, "api_rate": 1.17, "markup_pct": 2.5, "effective_rate": 1.19925,
  "fetched_at": "2026-07-30 01:02:20", "stale": false, "source": "fallback"
}

mode: "fixed" needs a positive fixed_rate — asking for it without one is a 400. The base currency is a 400 (GBP is the base currency — its rate is always 1), and a currency with no pair yet is a 404 telling you to create a price line in it first.

POST/v1/exchange-rates/refreshwrite

Pulls every enabled pair from the provider (frankfurter by default, which needs no API key). No body, no parameters.

This always answers 200. A provider that is down is a reported outcome, not a server error — and a failed refresh never clears a stored rate, so the last known number keeps serving. Check ok, and read reason when it is false.

curl -s -X POST https://simplypim.co.uk/v1/exchange-rates/refresh \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response — captured with the network unreachable
{
  "ok": false,
  "provider": "frankfurter",
  "base": "GBP",
  "updated": [],
  "skipped": ["EUR", "USD"],
  "fetched_at": null,
  "source": null,
  "reason": "fetch_failed",
  "message": "The operation was aborted due to timeout",
  "rates": [ /* the whole desk as it stands after the attempt */ ]
}
KeyMeaning
oktrue when at least one rate was written
updated / skippedQuotes written / quotes the provider did not answer for. A skipped pair keeps its stored rate
fetched_at / sourceOur write time and a provenance string; null on a hard failure
reasonFailure only. disabled, no_quotes, fetch_failed, http_error or invalid_payload, with a human message
ratesAlways present — the post-attempt state of every pair, so one call can drive a whole settings screen

Live fetching can be switched off platform-side, in which case every refresh answers reason: "disabled" and fixed and stored rates are used.

Convert an amount

GET/v1/fx/convertread

The same conversion the normalized blocks use, exposed for prefilling a price in another currency or sanity-checking a margin.

QueryNotes
amount_minor requiredInteger minor units — 295000 is £2,950.00. Negatives allowed
to required3-letter ISO code
from3-letter ISO code; defaults to the base currency
curl -s "https://simplypim.co.uk/v1/fx/convert?amount_minor=295000&to=EUR" \
  -H "X-API-Key: $SIMPLYPIM_KEY"

# cross-rate, neither side the base
curl -s "https://simplypim.co.uk/v1/fx/convert?amount_minor=295000&from=USD&to=EUR" \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 GBP → EUR
{
  "from": "GBP",
  "to": "EUR",
  "amount_minor": 345150,
  "source_amount_minor": 295000,
  "rate": 1.17,
  "rate_mode": "api",
  "as_of": "2026-07-30 01:02:20",
  "stale": false
}
200 USD → EUR, cross-rated through the base
{
  "from": "USD",
  "to": "EUR",
  "amount_minor": 271772,
  "source_amount_minor": 295000,
  "rate": 0.921259842519685,
  "rate_mode": "api",
  "as_of": "2026-07-30 01:02:20",
  "stale": false
}

amount_minor is the result; source_amount_minor echoes what you asked for. A cross-rate is one multiplication, rounded exactly once, so converting and converting back does not drift. rate_mode reads mixed when the two legs disagree, and as_of/stale report the older/least-fresh leg. An unknown currency is a 404; a known one with no usable rate is a 400.

Stock

Per-location quantities with allocation: free = qty − allocated. Search and recommendations treat free > 0 as in stock.

Unless the product is serial-tracked. Once a product carries units, they are the source of truth and its levels are computed from them — writing plain stock is then a 409. See Serial-tracked units for the handover rules.

PUT/v1/products/:sku/stockwrite

Upserts per location. GET /v1/stock-locations lists the codes.

curl -s -X PUT https://simplypim.co.uk/v1/products/NLAV-DOCS-1/stock \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stock": [
        {"location": "NEW-LON", "qty": 24, "allocated": 4},
        {"location": "SHOWROOM", "qty": 2}
      ]}'
200 response
{
  "sku": "NLAV-DOCS-1",
  "stock": [
    { "location": "NEW-LON",  "location_name": "London Warehouse", "qty": 24, "allocated": 4, "free": 20 },
    { "location": "SHOWROOM", "location_name": "London Showroom",  "qty": 2,  "allocated": 0, "free": 2 }
  ],
  "totals": { "qty": 26, "allocated": 4, "free": 22 }
}

Serial-tracked units

Two used camera bodies are not "quantity 2". One is grade A with 4,100 actuations at £2,495; the other is grade C with a scratched screen at £2,295, and a customer is entitled to know which one is arriving. Flip serial_tracked on and the product's stock becomes a list of individual units, each with its own serial, condition, grade, location, status and optional price.

Set serial_tracked: true on POST/PATCH /v1/products. The product then reports serial_tracked: true and a units_summary object; a plain product reports false and null.

FieldNotes
serial requiredUp to 120 characters, unique within the product
conditionAny conditions.code. Defaults to the product's own — and may legitimately differ from it: a showroom body under an otherwise used line is fine
gradeYour own free-text grade — A, B+, Excellent
location / location_idStock location by code or id. null for a unit held nowhere
statusin_stock (default), allocated, sold or returned
price_override / price_override_minorThis unit's own price — major or minor units, either is accepted, both are echoed. null means it takes the product's price
acquired_atYYYY-MM-DD or a full stamp. Defaults to now
sold_at, notesThe sale date, and up to 2,000 characters of condition notes to show a buyer

Which statuses hold stock. in_stock and allocated do — allocated means spoken for but not yet shipped. sold and returned do not: a returned unit is back from a customer but not yet re-saleable, so you flip it to in_stock once it has been checked over.

GET/v1/products/:sku/unitsread

The units, the summary, and the stock levels they produce — in one read. Filter with status, condition, location or location_id.

curl -s "https://simplypim.co.uk/v1/products/SONY-FX5-U/units?status=in_stock" \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response (trimmed to two units)
{
  "sku": "SONY-FX5-U",
  "serial_tracked": true,
  "count": 7,
  "units": [
    {
      "id": 13,
      "serial": "FX5-U-3320194",
      "condition": "used",
      "condition_meta": { "code": "used", "label": "Used", "color": "#0ea5e9", "sku_token": "U" },
      "grade": "B",
      "location": "NEW-MCR",
      "location_name": "Manchester Warehouse",
      "location_id": 2,
      "status": "in_stock",
      "price_override": null,
      "price_override_minor": null,
      "acquired_at": "2026-06-20 11:00:00",
      "sold_at": null,
      "notes": null
    },
    {
      "id": 11,
      "serial": "FX5-U-3320175",
      "condition": "used",
      "condition_meta": { "code": "used", "label": "Used", "color": "#0ea5e9", "sku_token": "U" },
      "grade": "C",
      "location": "NEW-LON",
      "location_name": "London Warehouse",
      "location_id": 1,
      "status": "in_stock",
      "price_override": 2995,
      "price_override_minor": 299500,
      "acquired_at": "2026-06-08 11:00:00",
      "sold_at": null,
      "notes": "Heavier wear on the base plate; screen has light scratches."
    }
  ],
  "units_summary": {
    "total": 10,
    "by_condition": { "new": 0, "used": 10, "demo": 0, "showroom": 0 },
    "by_status": { "in_stock": 7, "allocated": 1, "sold": 2, "returned": 0 },
    "available_units": { "new": 0, "used": 7, "demo": 0, "showroom": 0 },
    "price_range": { "min_minor": 299500, "max_minor": 319500, "min": 2995, "max": 3195 }
  },
  "stock": [
    { "location": "NEW-LON", "location_name": "London Warehouse",     "qty": 5, "allocated": 1, "free": 4 },
    { "location": "NEW-MCR", "location_name": "Manchester Warehouse", "qty": 3, "allocated": 0, "free": 3 }
  ],
  "stock_totals": { "qty": 8, "allocated": 1, "free": 7 }
}
units_summaryMeaning
totalEvery unit, whatever its status. Note it is 10 above while count is 7 — the filter narrows units, never the summary
by_conditionEvery unit, keyed by condition code. Keys follow your vocabulary, so a new condition appears here automatically
by_statusEvery unit, keyed by the four unit statuses
available_unitsin_stock units per condition — "how many used ones can I actually sell"
price_rangeCheapest and dearest in-stock unit that carries its own price, in both major and minor units. null when none does — which is what a "from £x" badge should key off

:sku also accepts a numeric product id. Every unit carries condition_meta so a list renders its chip without a lookup per row.

POST/v1/products/:sku/unitswrite

Takes one unit object, a bare array, or {"units": [...]} — up to 500 per call. Answers 201 with the created units and the recomputed derived state, so a UI never needs a follow-up GET.

A bulk add is all-or-nothing. One duplicate serial anywhere in the payload — or against a unit already stored — rolls the whole call back. Nothing is half-imported, so you can safely retry the same batch after fixing the offender.

curl -s -X POST https://simplypim.co.uk/v1/products/NLAV-DOCS-U/units \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"units": [
        {"serial": "NL-0001", "grade": "A", "location": "NEW-LON", "price_override": 2495.00,
         "acquired_at": "2026-07-02", "notes": "Boxed, 4,100 actuations."},
        {"serial": "NL-0002", "grade": "B", "location": "NEW-LON", "price_override": 2295.00,
         "acquired_at": "2026-07-11"},
        {"serial": "NL-0003", "grade": "A", "location": "NEW-MCR", "status": "allocated",
         "acquired_at": "2026-07-18"}
      ]}'
201 response (units trimmed to one)
{
  "sku": "NLAV-DOCS-U",
  "created": 3,
  "units": [
    {
      "id": 25,
      "serial": "NL-0001",
      "condition": "used",
      "condition_meta": { "code": "used", "label": "Used", "color": "#0ea5e9", "sku_token": "U" },
      "grade": "A",
      "location": "NEW-LON",
      "location_name": "London Warehouse",
      "location_id": 1,
      "status": "in_stock",
      "price_override": 2495,
      "price_override_minor": 249500,
      "acquired_at": "2026-07-02 00:00:00",
      "sold_at": null,
      "notes": "Boxed, 4,100 actuations."
    }
    // … NL-0002 and NL-0003
  ],
  "units_summary": {
    "total": 3,
    "by_condition": { "new": 0, "used": 3, "demo": 0, "showroom": 0 },
    "by_status": { "in_stock": 2, "allocated": 1, "sold": 0, "returned": 0 },
    "available_units": { "new": 0, "used": 2, "demo": 0, "showroom": 0 },
    "price_range": { "min_minor": 229500, "max_minor": 249500, "min": 2295, "max": 2495 }
  },
  "stock": [
    { "location": "NEW-LON", "location_name": "London Warehouse",     "qty": 2, "allocated": 0, "free": 2 },
    { "location": "NEW-MCR", "location_name": "Manchester Warehouse", "qty": 1, "allocated": 1, "free": 0 }
  ],
  "stock_totals": { "qty": 3, "allocated": 1, "free": 2 }
}

Note what happened to stock: the product had manual levels of 4 at NEW-LON before this call, and they were replaced by what the units say — two in London, one allocated in Manchester.

409 duplicate serials, both kinds
# against a unit already on the product
{ "error": "conflict", "message": "Unit 'FX5-U-3320175' already exists on product 'SONY-FX5-U'" }

# twice inside the same payload
{ "error": "conflict", "message": "Serial 'DOCS-A' appears twice in this request" }
400 over the bulk ceiling
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "units", "message": "Array must contain at most 500 element(s)" }
  ]
}
PATCH/v1/products/:sku/units/:idwrite

Any subset of the same fields — this is how a unit is sold, allocated, re-graded, repriced or moved between locations. Returns the updated unit plus the recomputed summary and levels.

curl -s -X PATCH https://simplypim.co.uk/v1/products/NLAV-DOCS-U/units/26 \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "sold", "sold_at": "2026-07-29", "notes": "Sold with a 6 month warranty."}'
200 response
{
  "sku": "NLAV-DOCS-U",
  "unit": {
    "id": 26,
    "serial": "NL-0002",
    "condition": "used",
    "condition_meta": { "code": "used", "label": "Used", "color": "#0ea5e9", "sku_token": "U" },
    "grade": "B",
    "location": "NEW-LON",
    "location_name": "London Warehouse",
    "location_id": 1,
    "status": "sold",
    "price_override": 2295,
    "price_override_minor": 229500,
    "acquired_at": "2026-07-11 00:00:00",
    "sold_at": "2026-07-29 00:00:00",
    "notes": "Sold with a 6 month warranty."
  },
  "units_summary": {
    "total": 3,
    "by_condition": { "new": 0, "used": 3, "demo": 0, "showroom": 0 },
    "by_status": { "in_stock": 1, "allocated": 1, "sold": 1, "returned": 0 },
    "available_units": { "new": 0, "used": 1, "demo": 0, "showroom": 0 },
    "price_range": { "min_minor": 249500, "max_minor": 249500, "min": 2495, "max": 2495 }
  },
  "stock": [
    { "location": "NEW-LON", "location_name": "London Warehouse",     "qty": 1, "allocated": 0, "free": 1 },
    { "location": "NEW-MCR", "location_name": "Manchester Warehouse", "qty": 1, "allocated": 1, "free": 0 }
  ],
  "stock_totals": { "qty": 2, "allocated": 1, "free": 1 }
}

Selling NL-0002 dropped London from 2 to 1 and took its £2,295 out of price_range — every derived number moves in the same response. DELETE /v1/products/:sku/units/:id hard-deletes a unit and answers 204; an id that does not belong to that product is a 404.

Unit-derived stock

A serial-tracked product's per-location levels are computed, so the plain stock endpoint would only be overwritten by the next unit write. It refuses instead:

409 PUT /v1/products/SONY-FX5-U/stock
{
  "error": "conflict",
  "message": "Stock is unit-derived: this product is serial-tracked and carries 10 unit(s), so its per-location levels are computed from them. Add, move or update units at /v1/products/{sku}/units instead."
}

The transitions follow from that one rule:

You do thisWhat happens
Turn serial_tracked onExisting manual levels are left alone and stay editable — nothing is derived until the first unit exists
Add the first unitLevels are recomputed from the units. A location with no units drops to 0. PUT …/stock is now a 409
Delete the last unitThe product is handed back to the plain path — PUT …/stock works again (its levels are already 0)
Turn serial_tracked off while units exist409 — delete the units first
409 turning tracking off with units still attached
{
  "error": "conflict",
  "message": "Product 'NLAV-DOCS-U' still has 3 serialised unit(s). Delete them before turning serial_tracked off."
}

A zero-unit serial-tracked product is therefore a normal product with a flag set: its units_summary reads all zeroes with price_range: null, and PUT /v1/products/:sku/stock answers 200. Every unit write also logs the moved levels to stock history, so the derived changes are auditable like any others.

Master & child lines

The FX5 you sell new, used, ex-demo and off the showroom floor is one camera with four price tags. Write the description, the spec and the photography once on a master, and each line reads it back until it has something of its own to say. Nothing is ever copied: a child stores NULL and the value is resolved on read — so fixing a typo on the master fixes every line with it.

Link a product with master (a SKU) or master_id on POST/PATCH /v1/products, or let POST /v1/products/:sku/lines create the line for you.

What is inherited, and what never is

ClassFieldsBehaviour
Always own sku, name, condition, status, serial_tracked, categories, prices, stock, units Never inherited, never resolved. These are the commercial and identity fields that are the whole reason a used line exists separately — a used body has its own price, its own stock and its own serials
Inheritable core description, brand, family, keywords Unset on the child resolves to the master's value. auto_keywords moves as a pair with keywords — one token governs both
Inheritable attributes attr.<code>, every one Per attribute: no stored row means the master's value. A row that exists is an override, even if it is empty-ish
Inheritable media media All-or-nothing. No images of its own means the master's whole gallery; one own image means a full override and none of the master's are mixed in

Those tokens — description, brand, family, keywords, media and attr.<code> — are one vocabulary used everywhere: in overridden_fields, in resolved.fields, and in the inherit list.

One level, one master. A child has at most one master, a master may not itself be a child, and a product that already has children cannot become one — no chains. Re-pointing a child is allowed through master/master_id; adding a second master through a relation write is a 409, and a self-link is a 400.

POST/v1/products/:sku/lineswrite

Creates a child line of this product in one call. condition is required and may be any defined condition; sku, name and status are optional — omitted, the SKU takes the condition's sku_token as a suffix, the name takes the condition label in brackets, and the status defaults to draft.

curl -s -X POST https://simplypim.co.uk/v1/products/SMALLRIG-CAGE-FX3/lines \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"condition": "used"}'
201 response (trimmed)
{
  "id": 39,
  "sku": "SMALLRIG-CAGE-FX3-U",
  "name": "SmallRig Camera Cage for Sony FX3 and FX30 (Used)",
  "brand": "SmallRig",
  "status": "draft",
  "condition": "used",
  "description": "Full aluminium cage with integrated NATO rails, cold shoes and an HDMI clamp, machined specifically for the FX3 and FX30 body.",
  "keywords": [],
  "auto_keywords": [],
  "serial_tracked": false,
  "master": {
    "id": 25,
    "sku": "SMALLRIG-CAGE-FX3",
    "name": "SmallRig Camera Cage for Sony FX3 and FX30"
  },
  "completeness": { "score": 85, "missing": ["price", "stock"] }
  // … plus attributes, media and the `resolved` block, all from the master
}

The new line already has a description, five attributes and an image — every one of them the master's, none of them copied. Its resolved.overridden_fields is [], and the only things it is missing are the two it must own: a price and stock. serial_tracked is deliberately not inherited — whether a line is serialised is a decision per line. A line counts against your plan like any product, so this can answer 402.

Reading a child: the resolved block

A child's top-level fields are the resolved ones — the payload is the truth a storefront should render, so every consumer written before inheritance existed keeps working and sees a complete product. The raw values are never lost: they live in resolved.own.

resolved is present on a child only — the key is absent on a standalone product, never null.

200 GET /v1/products/SONY-FX5-U (the resolved block, trimmed)
{
  "master": { "id": 5, "sku": "SONY-FX5", "name": "Sony FX5 Full-Frame Cinema Line Camera" },
  "fields": {
    "description": {
      "value": "Used Sony FX5 bodies, each fully tested by our service department and graded individually…",
      "source": "own"
    },
    "brand":  { "value": "Sony", "source": "master" },
    "family": { "value": { "id": 1, "code": "camera", "label": "Cameras", "attributes": [ /* … */ ] }, "source": "master" },
    "keywords":      { "value": ["gimbal camera", "documentary camera", "internal raw"], "source": "master" },
    "auto_keywords": { "value": ["fx 5", "e-mount", "full frame", "cinema line"], "source": "master" },
    "media": {
      "value": [{ "id": 7, "url": "https://placehold.co/400x300?text=SONY-FX5-U", "kind": "image", "position": 0 }],
      "source": "own"
    }
  },
  "attributes": {
    "warranty_months": { "value": 6,    "source": "own" },
    "mount":           { "value": "Sony E", "source": "master" },
    "sensor_size":     { "value": "Full-Frame", "source": "master" },
    "weight_g":        { "value": 1840, "source": "master" }
    // … one entry per resolved attribute code
  },
  "overridden_fields": ["description", "media", "attr.warranty_months"],
  "own": {
    "description": "Used Sony FX5 bodies, each fully tested by our service department…",
    "brand": null,
    "family": null,
    "keywords": [],
    "auto_keywords": [],
    "media": [{ "id": 7, "url": "https://placehold.co/400x300?text=SONY-FX5-U", "kind": "image", "position": 0 }],
    "attribute_codes": ["warranty_months"]
  }
}
KeyWhat it gives you
master{id, sku, name} — the name is there so a badge can read "from Sony FX5" with no second call
fieldsPer inheritable core field: the resolved value plus source: "own" | "master" — exactly what you need to badge every input in an editor
attributesThe same, per attribute code. Codes reading source: "own" are the overrides
overridden_fieldsThe tokens this child owns, field order then attr. order. The master's children[] mirrors this exactly
ownThe child's raw stored values — null/[] wherever it inherits. This is what an "override / revert" editor round-trips, and own.attribute_codes lists the attributes it has a row for

From the master's side, children[] reports each line with the tokens it overrides:

200 GET /v1/products/SONY-FX5children
[
  { "id": 8, "sku": "SONY-FX5-D", "name": "Sony FX5 Full-Frame Cinema Line Camera (Ex-Demo)",  "condition": "demo",     "status": "live", "overridden_fields": ["media", "attr.warranty_months"] },
  { "id": 6, "sku": "SONY-FX5-N", "name": "Sony FX5 Full-Frame Cinema Line Camera (New)",      "condition": "new",      "status": "live", "overridden_fields": ["media", "attr.in_the_box"] },
  { "id": 9, "sku": "SONY-FX5-S", "name": "Sony FX5 Full-Frame Cinema Line Camera (Showroom)", "condition": "showroom", "status": "live", "overridden_fields": ["media", "attr.key_feature"] },
  { "id": 7, "sku": "SONY-FX5-U", "name": "Sony FX5 Full-Frame Cinema Line Camera (Used)",     "condition": "used",     "status": "live", "overridden_fields": ["description", "media", "attr.warranty_months"] }
]

?resolve=false — the raw read

Add ?resolve=false to GET /v1/products/:sku and you get the child's own stored values at the top level with no resolved block — the cheap path for an editor that wants to know what is actually stored. On the FX5 used line that means brand: null, family: null, keywords: [] and a single attribute instead of nine.

Read?resolve default / true?resolve=false
Top-level fieldsResolvedThe child's own, null where it inherits
resolvedPresent (child only)Absent
master / childrenAlways reportedAlways reported — children[] without overridden_fields
completenessThe resolved scoreAlso the resolved score — a product has one completeness however you read it

Accepted values are true/1/yes and false/0/no; anything else is a 400 rather than a quietly different payload. For a product with no master and no children the two reads are byte-identical, and the only difference from a pre-inheritance payload is the two extra keys master: null and children: [].

Reverting an override

Overriding a field is just writing it. Going back is inherit: [...] on PATCH /v1/products/:sku: it clears the child's own value for each token so the field resolves from the master again. It is applied after the rest of the patch, so one call can override one field and revert another.

# hand the description and the brand back to the master
curl -s -X PATCH https://simplypim.co.uk/v1/products/SMALLRIG-CAGE-FX3-U \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inherit": ["description", "brand"]}'

# revert one attribute, override another, in one call
curl -s -X PATCH https://simplypim.co.uk/v1/products/SONY-FX5-U \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "Graded used FX5 bodies.", "inherit": ["attr.warranty_months", "media"]}'
200 after reverting both
{
  "description": "Full aluminium cage with integrated NATO rails, cold shoes and an HDMI clamp…",
  "brand": "SmallRig",
  "resolved": {
    "overridden_fields": [],
    "fields": {
      "description": { "value": "Full aluminium cage with integrated NATO rails…", "source": "master" },
      "brand": { "value": "SmallRig", "source": "master" }
    },
    "own": { "description": null, "brand": null }
  }
}
TokenClears
description, brand, familyThat column, so it resolves from the master
keywordsBoth keyword lists — manual and auto move together. auto_keywords is accepted as an alias
mediaThe child's whole gallery, so the master's is shown again
attr.<code>One attribute's stored row
attributesEvery own attribute row at once
400 the two ways to get this wrong
# the product has no master
{
  "error": "bad_request",
  "message": "Product 'SONY-FX3' has no master, so there is nothing to inherit. Link it to a master first (`master`), or send the field as null to clear it."
}

# a token that is not inheritable — prices are always own
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "inherit.0", "message": "Expected one of description, brand, family, keywords, media, attributes or attr.<code>" }
  ]
}

Up to 200 tokens per call. A child's updated_at is deliberately not bumped when its master is edited, so "changed since" cursors see no phantom edits — but the child's search index is re-projected immediately, so it becomes findable by the master's new wording straight away.

Relations

Four relation types — accessory_of, compatible_with, variant_of, replacement_for — power the recommendations waterfall and "works with" listings.

Direction matters. A row is child → type → parent: with parent SONY-FX3 and child SONY-NPFZ100 as accessory_of, the battery is an accessory of the camera. On a product record, relations.children are the things pointing at it (its accessories) and relations.parents are the things it points at. PUT replaces the children — so you post the accessory list to the camera.

PUT/v1/products/:sku/relationswrite
curl -s -X PUT https://simplypim.co.uk/v1/products/SONY-FX3/relations \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"relations": [
        {"type": "accessory_of", "sku": "SONY-NPFZ100", "position": 0},
        {"type": "accessory_of", "sku": "SMALLRIG-CAGE-FX3", "position": 1},
        {"type": "compatible_with", "sku": "ATOMOS-NINJA-V"}
      ]}'
200 response (trimmed)
{
  "sku": "SONY-FX3",
  "relations": {
    "children": [
      { "type": "accessory_of", "sku": "SONY-NPFZ100", "name": "Sony NP-FZ100 Rechargeable Battery Pack", "brand": "Sony", "status": "live", "position": 0 },
      { "type": "accessory_of", "sku": "SMALLRIG-CAGE-FX3", "name": "SmallRig Camera Cage for Sony FX3 and FX30", "brand": "SmallRig", "status": "live", "position": 1 },
      { "type": "compatible_with", "sku": "ATOMOS-NINJA-V", "name": "Atomos Ninja V 5-inch Recording Monitor", "brand": "Atomos", "status": "live", "position": 0 }
    ],
    "parents": []
  }
}

Relation suggestions (the inbox)

Linking accessories by hand works, and it does not scale. This closes the loop: the pixel records what shoppers actually looked at and basketed together, a mining pass scores those co-occurrences, and anything convincing lands in an inbox for a merchandiser to accept or reject. Nothing is ever linked automatically — a suggestion is a proposal with its evidence attached.

Two different things are called "suggestions" on this page. relation_hints[] comes out of AI enrichment: the model reads one product's copy and guesses what it pairs with. The inbox here is behavioural — it reads your real traffic and has no opinion about the text. The hints are a cold-start aid for a catalogue with no traffic yet; the inbox is what you use once the pixel has been live for a few weeks. Neither writes a relation without a human.

POST/v1/relation-suggestions/minewrite

Runs the mining pass now and upserts the inbox. Synchronous and transactional — the inbox never shows half a run — and idempotent: re-running it on unchanged events writes nothing, which is what makes it safe on a schedule.

curl -s -X POST "https://simplypim.co.uk/v1/relation-suggestions/mine" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response — a first run
{
  "examined_events": 34,
  "examined_sessions": 14,
  "pairs_scored": 2,
  "suggestions_created": 2,
  "suggestions_updated": 0,
  "suggestions_unchanged": 0,
  "skipped_existing_relation": 0,
  "skipped_rejected": 0
}
200 the same call again, after one was accepted and one rejected
{
  "examined_events": 34,
  "examined_sessions": 14,
  "pairs_scored": 2,
  "suggestions_created": 0,
  "suggestions_updated": 0,
  "suggestions_unchanged": 0,
  "skipped_existing_relation": 1,
  "skipped_rejected": 1
}
CounterMeans
examined_eventsview and add_to_basket events with a session id, inside the 90-day window
examined_sessionsDistinct sessions that survived the bot guard
pairs_scoredPairs that cleared both thresholds — the candidate set before suppression
suggestions_createdNew rows in the inbox
suggestions_updatedExisting pending rows whose score or evidence moved
suggestions_unchangedPending rows the run agreed with exactly — no write happened
skipped_existing_relationAlready linked in product_relations, either direction, any type — including by an earlier accept
skipped_rejectedHeld back by rejection memory

How a pair is scored

Events inside the 90-day window with a session id and a type of view or add_to_basket are grouped by session. Within a session each SKU is reduced to the moment it was first seen, giving an ordered list of distinct products; every ordered pair (a before b) is a directional co-occurrence. a is the anchor the shopper landed on and b is what they went on to look at or buy, so the proposal is b --accessory_of--> a.

the score
weighted_pairs = pair_sessions + (BASKET_WEIGHT - 1) × basket_sessions     # basket counts triple
support        = pair_sessions / total_sessions
lift           = (weighted_pairs × total_sessions) / (anchor_sessions × candidate_sessions)
score          = lift × log2(1 + weighted_pairs)

Lift asks "is this pair more common than chance?" — it is what stops a popular product being suggested next to everything. The log2 term is the support side: it rewards volume without letting one very rare, very correlated pair outrank a strong everyday one. A basketed co-occurrence is worth three viewed ones, because intent to buy is a stronger signal than curiosity.

ThresholdValueWhy
Mining window90 daysLong enough for seasonal pairs, short enough to forget discontinued ones
Minimum sessions per pair3The session floor. Two shoppers is a coincidence, not a pattern — below this a pair is never worth a human's attention
Minimum lift1.2Below this the pair is no better than chance
Basket weightAn add_to_basket co-occurrence counts as three views
Bot guard40 SKUsA session touching more distinct products than this is a crawler, and is dropped whole

Four suppression rules keep the inbox honest:

  • Unknown SKUs are skipped. The pixel accepts any SKU string; only products that exist can be linked.
  • Already-related pairs are skipped — either direction, any relation type. The inbox never proposes what you have already decided.
  • Only the stronger direction survives. You will never see "A suggests B" and "B suggests A" for the same two products.
  • Accepted pairs are left alone. They already produced a relation.
GET/v1/relation-suggestionsread

The inbox, strongest first. Defaults to pending — the queue of work.

QueryNotes
statuspending (default), accepted, rejected or all
parent_skuOnly suggestions anchored on this product — the "what pairs with this?" panel
limitDefault 50, max 200
offsetStandard paging; total is the unpaged count
curl -s "https://simplypim.co.uk/v1/relation-suggestions?status=pending" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response
{
  "status": "pending",
  "total": 2,
  "count": 2,
  "limit": 50,
  "offset": 0,
  "suggestions": [
    {
      "id": 1,
      "type": "accessory_of",
      "status": "pending",
      "score": 22.1895,
      "parent": {
        "sku": "SONY-FX30",
        "name": "Sony FX30 Super 35 Cinema Line Camera",
        "brand": "Sony",
        "status": "live"
      },
      "child": {
        "sku": "ATOMOS-NINJA-V",
        "name": "Atomos Ninja V 5-inch 4K HDR Monitor-Recorder",
        "brand": "Atomos",
        "status": "live"
      },
      "evidence": {
        "sessions": 4,
        "basket_sessions": 2,
        "anchor_sessions": 4,
        "candidate_sessions": 4,
        "total_sessions": 14,
        "support": 0.2857,
        "lift": 7,
        "window_days": 90
      },
      "evidence_text": "viewed together in 4 sessions, basketed together 2× · lift 7 · support 29%",
      "score_at_rejection": null,
      "created_at": "2026-07-30 01:45:22",
      "decided_at": null
    },
    {
      "id": 2,
      "type": "accessory_of",
      "status": "pending",
      "score": 15.9531,
      "parent": {
        "sku": "SONY-FX3",
        "name": "Sony FX3 Full-Frame Cinema Line Camera",
        "brand": "Sony",
        "status": "live"
      },
      "child": {
        "sku": "SAMSUNG-T7-1TB",
        "name": "Samsung T7 Shield Portable SSD 1TB",
        "brand": "Samsung",
        "status": "live"
      },
      "evidence": {
        "sessions": 6,
        "basket_sessions": 4,
        "anchor_sessions": 8,
        "candidate_sessions": 6,
        "total_sessions": 14,
        "support": 0.4286,
        "lift": 4.0833,
        "window_days": 90
      },
      "evidence_text": "viewed together in 6 sessions, basketed together 4× · lift 4.08 · support 43%",
      "score_at_rejection": null,
      "created_at": "2026-07-30 01:45:22",
      "decided_at": null
    }
  ]
}

evidence_text is the field to put in your UI. It is the same numbers as evidence, pre-written for a human — "viewed together in 4 sessions, basketed together 2× · lift 7 · support 29%" — so a merchandiser never has to read the JSON to decide. evidence stays there for anyone who wants to re-rank or chart it.

POST/v1/relation-suggestions/:id/acceptwrite

Writes the accessory_of relation, appended after the parent's existing children of that type, and marks the suggestion accepted. Idempotent — accepting twice leaves one relation row and reports relation_created: false the second time.

curl -s -X POST "https://simplypim.co.uk/v1/relation-suggestions/1/accept" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response
{
  "suggestion": {
    "id": 1,
    "type": "accessory_of",
    "status": "accepted",
    "score": 22.1895,
    "parent": { "sku": "SONY-FX30", "name": "Sony FX30 Super 35 Cinema Line Camera", "brand": "Sony", "status": "live" },
    "child":  { "sku": "ATOMOS-NINJA-V", "name": "Atomos Ninja V 5-inch 4K HDR Monitor-Recorder", "brand": "Atomos", "status": "live" },
    "evidence": {
      "sessions": 4, "basket_sessions": 2,
      "anchor_sessions": 4, "candidate_sessions": 4, "total_sessions": 14,
      "support": 0.2857, "lift": 7, "window_days": 90
    },
    "evidence_text": "viewed together in 4 sessions, basketed together 2× · lift 7 · support 29%",
    "score_at_rejection": null,
    "created_at": "2026-07-30 01:45:22",
    "decided_at": "2026-07-30 01:45:41"
  },
  "relation_created": true
}
200 accepting the same one again
{ "relation_created": false, "suggestion": { "status": "accepted", … } }
404 unknown id
{ "error": "not_found", "message": "Relation suggestion 999 not found" }
POST/v1/relation-suggestions/:id/rejectwrite

Marks it rejected and remembers the score it was rejected at in score_at_rejection. No relation is written.

curl -s -X POST "https://simplypim.co.uk/v1/relation-suggestions/2/reject" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (trimmed)
{
  "relation_created": false,
  "suggestion": {
    "status": "rejected",
    "score": 15.9531,
    "score_at_rejection": 15.9531,
    "decided_at": "2026-07-30 01:45:42"
  }
}

Rejection memory

A rejected pair does not come back just because the miner ran again. It returns only when the evidence is materially stronger — the new score must beat score_at_rejection by 1.5× — at which point the row re-opens as pending. Anything short of that is counted in skipped_rejected and stays out of the inbox.

That is the difference between a suggestion engine you can live with and one you learn to ignore. "No" means no until the world visibly changes.

status lifecycle
          mined
            ↓
        ┌ pending ┐
 accept │         │ reject
        ↓         ↓
   accepted    rejected ──── score improves 1.5× ───→ pending
        │
        └──→ writes an accessory_of relation, and feeds the
             'behavioural' tier in recommendations

What accepting changes on the storefront

Accepting does two things. The obvious one is the relation, which GET /v1/recommendations/accessories serves from its first tier with reason: "accessory". The second is subtler: the accepted suggestion itself is tier 3, reason: "behavioural", ranked by evidence rather than by a merchandiser's hand-set order.

In the normal case tier 1 has already claimed the product, so you see accessory. The behavioural tier is what keeps a proven pairing on the page when the relation row is later re-organised away — reorder or replace a product's accessories and the evidence-backed pair survives:

200 GET /v1/recommendations/accessories?sku=SONY-FX30 after the relation was replaced without ATOMOS-NINJA-V
SONY-NPFZ100           accessory
RODE-VMICPRO+          accessory
SMALLRIG-CAGE-FX3      accessory
ATOMOS-NINJA-V         behavioural     ← the accepted suggestion, not a relation row
CANON-C70              popular
CANON-C70-USED         popular

Only accepted suggestions scoring at least 1 reach that tier, and — like every tier — a candidate must have a publicly visible status to appear.

SKU generation

Product IDs can follow a pattern instead of being typed by hand. Conventions are scoped — the most specific active one wins: category > family > global — and pattern tokens are {BRAND:n}, {CAT:n}, {FAM:n}, {COND} (N/U/D) and {SEQ} (a zero-padded counter). Everything else in a pattern is literal.

POST/v1/skus/generatewrite

Renders the convention that matches a draft product. Pass {"preview": true} to see the next ID without consuming the counter — that is what the dashboard's "Generate" button does, so an abandoned form never burns an ID. Omitting sku on POST /v1/products does the same thing atomically inside the create.

curl -s -X POST https://simplypim.co.uk/v1/skus/generate \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"brand": "Northlight", "category": "lenses", "condition": "used", "preview": true}'
200 response
{
  "sku": "NOR-0002",
  "convention_id": 1,
  "pattern": "{BRAND:3}-{SEQ}",
  "seq": 2,
  "preview": true,
  "attempts": 1,
  "convention": {
    "id": 1, "scope": "global",
    "category_id": null, "category_slug": null, "category_name": null,
    "family_id": null, "family_code": null, "family_label": null,
    "pattern": "{BRAND:3}-{SEQ}", "next_seq": 2, "seq_pad": 4, "active": true
  }
}

POST /v1/skus/preview renders an arbitrary unsaved pattern (no counter, no write). Conventions themselves live at GET/POST /v1/sku-conventions and PATCH/DELETE /v1/sku-conventions/:id; the GET also returns the token palette.

Search keywords

Customers search for "s35", "half cage" and "fx-3" — words that appear nowhere in a manufacturer's copy. Every product therefore carries two independent keyword lists, and the distinction between them is the whole point: one is yours and the machine never touches it.

FieldWho writes itCap
keywords You. Writable on POST/PATCH /v1/products. Enrichment never reads or writes it 50
auto_keywords Enrichment only, and read-only over the API. Replaced wholesale on every run 15

Both are indexed for search, weighted just below the product name, and the merged pair is what a query actually matches. Sending keywords replaces the whole manual list; [] clears it. Values are canonicalised on the way in — lowercased, whitespace collapsed, duplicates and single letters dropped — so "Half  CAGE" stores as "half cage".

Re-enriching is safe. A run replaces auto_keywords and leaves keywords exactly as it was. The two lists are separate columns, so a merchandiser's carefully chosen terms can never be overwritten by a model — and auto-keywords are de-duplicated against the manual list before being stored, so you never see the same term twice.

200 the two lists on a product payload
{
  "sku": "SAMSUNG-T7-1TB",
  "keywords": ["backup drive", "proxy storage", "offload"],
  "auto_keywords": ["portable drive", "external drive", "usb-c ssd", "t 7"]
}
Setting the manual list
curl -s -X PATCH https://simplypim.co.uk/v1/products/NLAV-DOCS-E \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keywords": ["fx3 cage", "quick release", "Half  CAGE"]}'

# stored as: ["fx3 cage", "quick release", "half cage"]
POST/v1/products/:sku/keywords/suggestread

Keyword candidates for the approve flow — synonyms, use-cases, category and mount jargon ("e-mount", "s35", "shotgun mic") and the model code written the ways people actually type it (t7, t-7, t 7).

Nothing is saved. Approve the ones you want by PATCHing them into keywords. Terms already on either list are filtered out, so every suggestion you get back is one you do not already have.

curl -s -X POST https://simplypim.co.uk/v1/products/SENN-MKE600/keywords/suggest \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "sku": "SENN-MKE600",
  "suggestions": ["audio", "sennheiser audio", "microphone", "mic", "shotgun mic", "xlr", "shotgun", "sennheiser"],
  "existing": { "keywords": [], "auto_keywords": [] },
  "fallback": true,
  "fallback_reason": "no_api_key"
}
200 a product that already has both lists
{
  "sku": "SAMSUNG-T7-1TB",
  "suggestions": [
    "samsung t7", "t7", "samsung t-7", "t-7", "samsung t 7",
    "samsung 1tb", "1tb", "portable ssd", "samsung portable ssd",
    "media & storage", "samsung media & storage", "ssd", "1000gb", "1000 gb", "black"
  ],
  "existing": {
    "keywords": ["backup drive", "proxy storage", "offload"],
    "auto_keywords": ["portable drive", "external drive", "usb-c ssd", "t 7"]
  },
  "fallback": true,
  "fallback_reason": "no_api_key"
}

existing is echoed so a UI can render both lists beside the candidates without a second call — and so an "append" PATCH is one line, as in the JavaScript tab. At most 15 suggestions come back. fallback: true means the deterministic generator produced these rather than a model; the shape is identical either way.

On a child line, keywords and auto_keywords are inherited as a pair: a line with no manual keywords of its own shows the master's both lists, and inherit: ["keywords"] clears both.

AI enrichment & price suggestions

POST/v1/products/:sku/enrichwrite

Fills in the description, search keywords, family-schema attributes and SEO fields from whatever the product already carries (including raw_source_text). Existing values are never overwritten unless you pass {"overwrite": true}, and you can limit the run with {"fields": ["description"]}. It cannot fail because of the AI: with no model key configured it falls back to a deterministic extractor and tells you so.

{"depth": "minimal" | "standard" | "full"} chooses how much of the product the run is allowed to touch — see Enrichment depth for exactly which fields each profile writes. Omit it to use the installation default (enrichment_depth in /v1/settings). Auto-keywords replace the machine-generated list only — keywords a person typed are never touched.

curl -s -X POST https://simplypim.co.uk/v1/products/NLAV-DOCS-1/enrich \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
200 response
{
  "sku": "NLAV-DOCS-E",
  "depth": "standard",
  "fields": ["description", "attributes", "seo", "keywords"],
  "applied": {
    "description": null,
    "attributes": [
      { "code": "warranty_months", "value": 12 },
      { "code": "weight_g", "value": 320 }
    ],
    "seo": {
      "seo_title": "Northlight Cage for Sony FX3",
      "seo_description": "Includes NATO rail, two cold shoes and an HDMI cable clamp. 12 month warranty."
    },
    "auto_keywords": [
      "northlight fx3", "fx3", "northlight fx-3", "fx-3", "camera support & rigging",
      "video camera", "camera cage", "rig", "rigging", "northlight"
    ]
  },
  "skipped": ["description"],
  "skipped_detail": [
    { "field": "description", "reason": "already set — pass {\"overwrite\": true} to replace it" }
  ],
  "fallback": true,
  "completeness": { "before": 15, "after": 15 },
  "fallback_reason": "no_api_key"
}

applied.description is null here because the product already had one — skipped_detail always says why a field was left alone. fallback: true with fallback_reason: "no_api_key" means the deterministic extractor did the work; the response shape is identical when a model does it, with a model field added.

Enrichment depth

A depth is a field budget, nothing more. The application rules never change with it: an existing value is still never replaced without {"overwrite": true}, attributes outside the product's family are still rejected, and manual keywords are still untouched at every depth.

Writes minimal standard full
descriptionyesyesyes
auto_keywordsyesyesyes
attributes — the product's effective schemayesyes
seoseo_title, seo_descriptionyesyes
Returns relation_hints[] review onlyyes
Returns price_suggestion review onlyyes

full writes exactly what standard writes. Its two extras are additions to the response, not to your data — so a deeper run can never change more of the product than a shallower one. Nothing is linked and nothing is priced by enrichment, ever: a human accepts a hint from the editor.

An explicit fields list is intersected with the depth's budget rather than overriding it, and anything outside is reported instead of being silently dropped:

200 {"depth": "minimal", "fields": ["attributes"]} — writes nothing, and says why
{
  "sku": "NLAV-DOCS-E",
  "depth": "minimal",
  "fields": [],
  "applied": { "description": null, "attributes": [], "seo": null, "auto_keywords": [] },
  "skipped": ["attributes"],
  "skipped_detail": [
    { "field": "attributes", "reason": "not part of the 'minimal' enrichment depth — use a deeper profile" }
  ],
  "fallback": true,
  "completeness": { "before": 15, "after": 15 },
  "fallback_reason": "no_api_key"
}
200 {"depth": "minimal"} on a fresh product
{
  "sku": "NLAV-DOCS-E",
  "depth": "minimal",
  "fields": ["description", "keywords"],
  "applied": {
    "description": "Includes NATO rail, two cold shoes and an HDMI cable clamp. 12 month warranty. Key specifications: Warranty 12 months, Weight 320 g. The Northlight Cage for Sony FX3 sits in the Camera Support & Rigging range.",
    "attributes": [],
    "seo": null,
    "auto_keywords": ["northlight fx3", "fx3", "camera cage", "rig", "rigging", "northlight"]
  },
  "skipped": [],
  "skipped_detail": [],
  "fallback": true,
  "completeness": { "before": 0, "after": 15 },
  "fallback_reason": "no_api_key"
}

The full extras

Both are proposals for a human to approve, and both are absent from the response at minimal and standard.

KeyWhat it is
relation_hints[] Up to 8 relations worth creating, each {sku, name, type, source, reason, score?}. source is "behaviour" (mined from pixel sessions — "people who looked at this basketed that", and the only one carrying a score) or "attributes" (a shared compatibility attribute such as mount or media_type, across different families — same-family matches are competitors, not accessories). reason is human-readable text you can render straight into an editor. Anything already linked in either direction is filtered out, so every hint is actionable. Not the same thing as the relation-suggestions inbox — these are produced on demand for one product and are not stored or tracked, whereas the inbox is a persistent, catalogue-wide queue with accept/reject state and rejection memory
price_suggestion A fresh run of the same explainable engine as GET /v1/products/:sku/price-suggestion, in the same shape — cost floor, target margin, category anchor, RRP clamp and the rationale strings. null when the product has no price to reason about

Both come back [] / null rather than absent on a full run that found nothing — a brand-new product with no price and no compatibility attributes is the usual case.

GET/v1/products/:sku/price-suggestionread

An explainable price: cost floor, target margin, category anchor, stock pressure, RRP clamp and charm rounding — with the reasoning as text you can show a merchandiser.

curl -s https://simplypim.co.uk/v1/products/NLAV-DOCS-1/price-suggestion \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "sku": "NLAV-DOCS-1",
  "currency": "GBP",
  "current": 129.99,
  "cost": 74.5,
  "margin_now_pct": 42.7,
  "suggested": 116.99,
  "margin_suggested_pct": 36.3,
  "floor": 80.46,
  "rationale": [
    "Cost £74.50: margin floor £80.46 (x1.08) and target £87.91 (x1.18); the current price is £129.99.",
    "Category \"Camera Support & Rigging\" median is £115.00 across 3 live products — pulled the target 50% toward it (£101.46).",
    "Anchor capped at +/-10% of the current price — held at £116.99."
  ],
  "inputs": {
    "status": "draft", "rrp": 149.99,
    "category": "Camera Support & Rigging", "category_median": 115, "category_sample": 3,
    "free_stock": 22, "events_30d": 0
  }
}

Insights & history

Every product carries its own time series: demand from the pixel, an append-only log of every price and stock change, and a day-by-day overlay of price against free stock against demand — which is what answers “did the price cut actually move units”.

GET/v1/products/:sku/insightsread
QueryNotes
daysWindow length ending today, default 90. Clamped into 1–730 rather than refused, so a chart control asking for five years gets two years. A non-numeric value is a 400
KeyWhat it is
viewsPixel events bucketed by UTC day: views, clicks, basket_adds. Sparse — only days with activity appear
price_historyAppend-only price changes: one row per changed field (amount, cost, rrp, deal) per price line, with old/new in major and minor units and a source (ui, api, import…)
stock_historyAppend-only level changes per location with old/new qty, allocated and free
price_stock_overlayOne row per day in the window — the day's effective sell price, free stock, views and basket adds. Values are carried forward from the change logs, so there are no gaps to interpolate in your chart
competitor_pricesThe full capture log, newest first
competitor_latestThe most recent capture per competitor — what you compare against today
currentToday's price (with deal state), free stock and completeness, so the header needs no second call
curl -s "https://simplypim.co.uk/v1/products/SONY-FX3/insights?days=90" \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response (trimmed)
{
  "sku": "SONY-FX3",
  "days": 90,
  "from": "2026-05-01",
  "to": "2026-07-29",
  "views": [
    { "day": "2026-06-14", "views": 1, "clicks": 0, "basket_adds": 0 },
    { "day": "2026-06-15", "views": 1, "clicks": 0, "basket_adds": 0 }
    // … one row per day with activity
  ],
  "price_history": [
    {
      "id": 1, "price_list": "web-uk", "label": "Web UK", "currency": "GBP",
      "field": "cost",
      "old_minor": 279900, "new_minor": 271731, "old": 2799, "new": 2717.31,
      "changed_at": "2026-05-30 09:15:00", "source": "import"
    },
    {
      "id": 3, "price_list": "web-uk", "label": "Web UK", "currency": "GBP",
      "field": "amount",
      "old_minor": 319900, "new_minor": 304900, "old": 3199, "new": 3049,
      "changed_at": "2026-06-15 09:15:00", "source": "ui"
    }
  ],
  "stock_history": [
    {
      "id": 1, "location": "NEW-LON", "location_name": "London Warehouse",
      "old_qty": 24, "new_qty": 20,
      "old_allocated": 4, "new_allocated": 6,
      "old_free": 20, "new_free": 14,
      "changed_at": "2026-06-14 17:40:00", "source": "api"
    }
  ],
  "price_stock_overlay": [
    { "day": "2026-05-01", "price_minor": 319900, "price": 3199, "free_stock": 23, "views": 0, "basket_adds": 0 },
    { "day": "2026-06-15", "price_minor": 304900, "price": 3049, "free_stock": 14, "views": 1, "basket_adds": 0 }
    // … exactly `days` rows, oldest first
  ],
  "competitor_prices": [
    { "id": 3, "competitor": "Wex Photo Video", "url": "https://www.wexphotovideo.com/sony-fx3", "amount_minor": 292500, "amount": 2925, "currency": "GBP", "captured_at": "2026-07-24 08:00:00" },
    { "id": 2, "competitor": "CameraWorld", "url": null, "amount_minor": 297500, "amount": 2975, "currency": "GBP", "captured_at": "2026-07-22 08:00:00" },
    { "id": 1, "competitor": "Wex Photo Video", "url": "https://www.wexphotovideo.com/sony-fx3", "amount_minor": 299900, "amount": 2999, "currency": "GBP", "captured_at": "2026-06-19 08:00:00" }
  ],
  "competitor_latest": [
    { "id": 3, "competitor": "Wex Photo Video", "url": "https://www.wexphotovideo.com/sony-fx3", "amount_minor": 292500, "amount": 2925, "currency": "GBP", "captured_at": "2026-07-24 08:00:00" },
    { "id": 2, "competitor": "CameraWorld", "url": null, "amount_minor": 297500, "amount": 2975, "currency": "GBP", "captured_at": "2026-07-22 08:00:00" }
  ],
  "current": {
    "price": {
      "price_list": "web-uk", "label": "Web UK", "currency": "GBP",
      "amount": 2950, "amount_minor": 295000,
      "effective_amount": 2950, "effective_amount_minor": 295000,
      "deal_active": false
    },
    "free_stock": 54,
    "completeness": { "score": 100, "missing": [] }
  }
}

The overlay charts the web-uk sell line when the product has one, otherwise the first sell line by position. Days before the first recorded change use that change's old value, and free stock is walked backwards from today's total through the change log — so the series is complete even for a product whose history starts mid-window. An unknown SKU is 404 not_found; a product with no events, no history and no rivals returns the same shape with empty arrays.

Competitor prices

Track what rivals charge — entered by hand today, and the same shape a scraper or feed will post later. Amounts are major units and the currency defaults to the product's own sell currency.

GET/v1/products/:sku/competitor-pricesread
curl -s https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response
{
  "sku": "SONY-FX3",
  "competitor_prices": [
    { "id": 3, "competitor": "Wex Photo Video", "url": "https://www.wexphotovideo.com/sony-fx3", "amount_minor": 292500, "amount": 2925, "currency": "GBP", "captured_at": "2026-07-24 08:00:00" },
    { "id": 2, "competitor": "CameraWorld", "url": null, "amount_minor": 297500, "amount": 2975, "currency": "GBP", "captured_at": "2026-07-22 08:00:00" },
    { "id": 1, "competitor": "Wex Photo Video", "url": "https://www.wexphotovideo.com/sony-fx3", "amount_minor": 299900, "amount": 2999, "currency": "GBP", "captured_at": "2026-06-19 08:00:00" }
  ]
}
POST/v1/products/:sku/competitor-priceswrite
FieldNotes
competitor requiredWho you saw it at, up to 120 characters
amount requiredMajor units, e.g. 2895.00
urlThe listing you captured, so a merchandiser can re-check it
currency3-letter ISO code; defaults to the product's sell currency
captured_atYYYY-MM-DD or YYYY-MM-DD HH:MM:SS; defaults to now. Back-date to build history
curl -s -X POST https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "competitor": "Park Cameras",
        "amount": 2895.00,
        "url": "https://www.parkcameras.com/sony-fx3",
        "captured_at": "2026-07-29"
      }'
201 response
{
  "id": 5,
  "competitor": "Park Cameras",
  "url": "https://www.parkcameras.com/sony-fx3",
  "amount_minor": 289500,
  "amount": 2895,
  "currency": "GBP",
  "captured_at": "2026-07-29 00:00:00"
}

PATCH /v1/products/:sku/competitor-prices/:id edits any of those fields and returns the updated capture; DELETE the same path answers 204. Both are 404 not_found when the id does not belong to that product.

PATCH/v1/products/:sku/competitor-prices/:idwrite
curl -s -X PATCH https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices/5 \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 2879.99}'

# and to remove a capture:
curl -s -o /dev/null -w '%{http_code}\n' \
  -X DELETE https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices/5 \
  -H "X-API-Key: $SIMPLYPIM_KEY"
# 204
200 response
{
  "id": 5,
  "competitor": "Park Cameras",
  "url": "https://www.parkcameras.com/sony-fx3",
  "amount_minor": 287999,
  "amount": 2879.99,
  "currency": "GBP",
  "captured_at": "2026-07-29 00:00:00"
}

One parameter: q, in the words your customers actually use. “256gb drive in stock under £200” becomes a capacity filter, a price ceiling, a stock filter and the free text “drive” — then ranks what is left and hands you facets for the sidebar.

Which price "under £300" means. Price filters and the price shown on a result both read your install's default sell listweb-uk when you have one, otherwise your first sell line by position then code, never a cost line. They are the same list by construction, so a product that displays £167 can always be found by "under 300". (Before 2026-08-27 the filter alone was hard-wired to web-uk: on an install without that list every price filter returned nothing while prices displayed perfectly.)

Two things worth knowing before you tune anything.

  • Search learns from your shoppers. What people click after a given phrase is rolled up (GET /v1/search/learning) and ranked in as query_click; what is in demand right now is ranked in as trending. Both are visible per result in matched.boosts, both decay with a 7-day half-life over a 28-day window, and both are inert on an install with no pixel traffic. Set either boost to 0 in Search → Relevance to ignore behaviour entirely.
  • When a product is missing, ask the server why rather than guessing: GET /v1/search/diagnose?sku=…&q=… runs the same checks search does, in order, and names the first one that excludes it — status, index, composition, a word that appears nowhere on the product, a filter the parser extracted, or simply a low rank. It is the Why not? tab of the search workbench.
GET/v1/search/suggestsearch · key required by default

Autocomplete, for a box somebody is still typing in. Four kinds of suggestion, in the order they are useful: query (phrases this shop's own customers have searched and found something with), category, brand, and product last.

curl -s "https://api.simplypim.co.uk/v1/search/suggest?q=18in&limit=6" \
  -H "X-API-Key: spim_a1b2c3_…"
200 response
{
  "q": "18in",
  "suggestions": [
    { "kind": "query",    "text": "18in monitor", "searches": 42 },
    { "kind": "category", "text": "Monitors", "slug": "monitors" },
    { "kind": "brand",    "text": "TVLogic" },
    { "kind": "product",  "text": "TVLogic SVM-183S 18.5in LCD Monitor", "sku": "CVP-TVL-N50256" }
  ],
  "corrected": null
}
QueryNotes
q requiredWhat has been typed so far, up to 120 characters. Blank returns an empty list, never the catalogue.
limitTotal suggestions across all four kinds. Default 8, max 20.

It never suggests a dead end. A past query is only offered if it returned something, and the departments and brands come from the catalogue itself — a shop that proposes its own phrase and then answers "no results" looks broken in a way that is entirely its own fault. corrected carries a spelling fix (panasnicpanasonic) so it arrives while somebody is still typing rather than after they press Enter, and products are only looked up once the prefix is at least 3 characters — below that every product matches and the list is noise. The route is cached like /v1/search: every shopper typing "cam" asks for the same answer.

<spim-search> uses this automatically — word suggestions above the product previews, arrow keys walking the whole list. Turn it off with term-suggestions="false".

GET/v1/searchsearch · key required by default
QueryNotes
q requiredUp to 300 characters. ?q= (blank) is a valid empty state: 200 with count: 0 rather than the whole catalogue
limit / offsetDefault 20, max 100 / default 0. count is the size of the whole filtered set, so paging is stable
statusDefaults to live. any searches drafts and archived too (for internal tools)

What the parser understands today, deterministically (no model call, no latency):

Example queryBecomes
256gb drive in stock under £200capacity_gb: 256, in_stock: true, price_max: 200, text “drive”
sony camera over 3000brand: "Sony", price_min: 3000, text “camera”
monitor around 300category: "monitors", price_min: 240, price_max: 360 — a BAND, ±20% (also about, approx, circa, roughly). A bound you state outright always wins over the band.
18in monitorcategory: "monitors", text “18in” — a department named in the query narrows the search, and is DROPPED (reported in parsed.relaxed) if keeping it would return nothing
black cage in stockcolor: "black", in_stock: true, text “cage”
used fx3condition: "used", text “fx3”
cfexpress type aPure free text — FTS across SKU, name, brand, description, attributes and category names
curl -s -G https://simplypim.co.uk/v1/search \
  --data-urlencode "q=256gb drive in stock under £200" \
  -H "X-API-Key: spim_8a3033_…" \
  -H "Origin: https://shop.example.com"
200 response
{
  "query": "256gb drive in stock under £200",
  "parsed": {
    "text": "drive",
    "filters": { "capacity_gb": 256, "price_max": 200, "in_stock": true }
  },
  "count": 2,
  "results": [
    {
      "sku": "LEXAR-CFXA-256G",
      "name": "Lexar Professional GOLD CFexpress Type A 256GB Card",
      "brand": "Lexar",
      "price": 169.17,
      "deal_price": null,
      "currency": "GBP",
      "in_stock": true,
      "free_stock": 20,
      "image": "https://placehold.co/400x300?text=LEXAR-CFXA-256G",
      "completeness": 100,
      "score": 7.215,
      "matched": {
        "text": true,
        "sku": false,
        "name": false,
        "filters": ["capacity_gb", "price_max", "in_stock"],
        "bm25": -1.215,
        "boosts": ["in_stock", "live"]
      }
    },
    {
      "sku": "ANGELBIRD-CFXA-256",
      "name": "Angelbird AV PRO CFexpress Type A 256GB Card",
      "brand": "Angelbird",
      "price": 207.59,
      "deal_price": 189.99,
      "currency": "GBP",
      "in_stock": true,
      "free_stock": 57,
      "image": "https://placehold.co/400x300?text=ANGELBIRD-CFXA-256",
      "completeness": 100,
      "score": 7.201,
      "matched": {
        "text": true, "sku": false, "name": false,
        "filters": ["capacity_gb", "price_max", "in_stock"],
        "bm25": -1.201,
        "boosts": ["in_stock", "live"]
      }
    }
  ],
  "facets": {
    "brand": { "Angelbird": 1, "Lexar": 1 },
    "category": { "CFexpress Cards": 2 },
    "color": { "black": 2 },
    "capacity_gb": { "256": 2 },
    "in_stock": { "true": 2 }
  }
}

Notice the £207.59 card in a “under £200” search: its active deal price (£189.99) is what the filter used, because that is what a customer pays. parsed is there so you can echo the interpretation back (“256 GB · in stock · under £200”) and let people remove a filter. facets are counted over the whole filtered set, not just the page — category counts can exceed count because a product may be filed in several categories. matched explains the ranking, which is invaluable when tuning a search box.

Embed search on your own site

Create a key with the search scope only, list your storefront domains on it, set a per-minute limit, and paste this in. The key is visible in page source — that is fine, it is domain-locked, read-nothing and rate limited.

<!-- SimplyPIM search box ------------------------------------------------ -->
<input id="pim-q" type="search" placeholder="Try: 256gb drive in stock under £200" autocomplete="off">
<ul id="pim-results"></ul>

<script>
  const PIM_BASE = 'https://simplypim.co.uk';
  const PIM_KEY  = 'spim_8a3033_…';          // publishable: search scope + your domains only

  const box  = document.getElementById('pim-q');
  const list = document.getElementById('pim-results');
  let timer, controller;

  box.addEventListener('input', () => {
    clearTimeout(timer);
    timer = setTimeout(run, 180);            // debounce keystrokes
  });

  async function run() {
    const q = box.value.trim();
    if (!q) { list.innerHTML = ''; return; }

    controller?.abort();                     // drop the in-flight request
    controller = new AbortController();

    const url = `${PIM_BASE}/v1/search?q=${encodeURIComponent(q)}&limit=8`;
    const res = await fetch(url, {
      headers: { 'X-API-Key': PIM_KEY },
      signal: controller.signal,
    });

    if (res.status === 429) {                // back off politely
      list.innerHTML = '<li>Too many searches — one moment…</li>';
      return;
    }
    if (!res.ok) { list.innerHTML = ''; return; }

    const { results, parsed } = await res.json();
    list.innerHTML = results.map((p) => `
      <li>
        <a href="/products/${p.sku}">
          <img src="${p.image ?? ''}" alt="" width="48" height="48">
          <strong>${p.name}</strong>
          <span>£${(p.deal_price ?? p.price ?? 0).toFixed(2)}</span>
          ${p.in_stock ? '<em>In stock</em>' : '<em>Backorder</em>'}
        </a>
      </li>`).join('');

    // Optional: show how the query was understood, e.g. "256 GB · in stock · under £200"
    console.debug('interpreted as', parsed.filters);
  }
</script>

CORS is already open for these routes, and the same key also authorises recommendations and events — so one publishable key powers search, the accessory widget and its telemetry.

Recommendations

GET/v1/recommendations/accessoriessearch · open by default

“Goes well with this”, resolved by a waterfall: linked accessories → behaviour-mined pairings → compatible products → popular in the same category → same brand. Out-of-stock products are excluded unless you ask for them.

QueryNotes
sku requiredThe product being viewed
limitDefault 6, max 24 — it is a widget, not a listing. 25 is a 400 validation_error on limit
include_oostrue keeps out-of-stock candidates
curl -s "https://simplypim.co.uk/v1/recommendations/accessories?sku=SONY-FX3&limit=3"
200 response
{
  "sku": "SONY-FX3",
  "limit": 3,
  "include_oos": false,
  "count": 3,
  "items": [
    {
      "sku": "SONY-NPFZ100",
      "name": "Sony NP-FZ100 Rechargeable Battery Pack",
      "image": "https://placehold.co/400x300?text=SONY-NPFZ100",
      "price": 74.99,
      "deal_price": null,
      "currency": "GBP",
      "in_stock": true,
      "reason": "accessory"
    },
    {
      "sku": "SMALLRIG-CAGE-FX3",
      "name": "SmallRig Camera Cage for Sony FX3 and FX30",
      "image": "https://placehold.co/400x300?text=SMALLRIG-CAGE-FX3",
      "price": 69,
      "deal_price": null,
      "currency": "GBP",
      "in_stock": true,
      "reason": "accessory"
    },
    {
      "sku": "TILTA-CAGE-FX3",
      "name": "Tilta Full Camera Cage for Sony FX3",
      "image": "https://placehold.co/400x300?text=TILTA-CAGE-FX3",
      "price": 115,
      "deal_price": null,
      "currency": "GBP",
      "in_stock": true,
      "reason": "accessory"
    }
  ]
}

reason is which tier produced the item — accessory, behavioural, compatible, popular or brand — so you can label the slot (“Frequently bought together”).

The accessory pixel

Don't want to build the widget? Drop in the pixel: under 8 KB, no dependencies, renders in a Shadow DOM so your CSS is untouched, and it reports view/impression/click/add-to-basket back to your PIM. It mounts into #pim-accessories if that element exists, otherwise a dismissable bottom-right panel.

<!-- Where the widget renders (optional — it falls back to a corner panel) -->
<div id="pim-accessories"></div>

<script src="https://simplypim.co.uk/pixel.js"
        data-site="northlight-av"
        data-sku="SONY-FX3"
        data-limit="6"
        data-title="Goes well with this"
        data-product-url-template="/p/{sku}"
        data-basket-selector=".add-to-basket"
        defer></script>

<!--
  data-site                    your site id, stamped on every event
  data-sku                     the product on this page (or <meta name="pim:sku" content="…">)
  data-limit                   how many accessories to render (default 6)
  data-title                   the widget heading
  data-product-url-template    where a recommendation links to; {sku} is substituted
  data-basket-selector         clicks on this selector fire add_to_basket, reading data-sku
  data-api                     only needed when the PIM host differs from the script host
-->

Add-to-basket events are what feed the behavioural accessory suggestions in the dashboard (“viewed together in 34 sessions, basketed together 6×”), so wiring data-basket-selector pays for itself.

Events

POST/v1/eventssearch · open by default

Storefront telemetry. Types: view, widget_impression, widget_click, add_to_basket — those four exactly, and anything else is dropped. Always answers 204 with no body — a malformed event is discarded rather than failing your page. Group events with your own session_id so the miner can spot pairings.

BodyNotes
site_id requiredYour site id, ≤ 120 chars. Stamped on the event and on the ingest counters, so this is what names you in the stats
type requiredOne of the four above. A missing or unknown type is reported as unknown_type
sku required≤ 64 chars, stored as sent. A SKU that is not in the catalogue is still stored — see unattributed
session_id≤ 120 chars. Your own id, not a cookie we set. Without it an event cannot join a session, and the miner works on sessions
payloadAny JSON. Capped at 2 KB serialised: over that the row keeps {"truncated": true, "bytes": n} instead, so the event survives and you can see it was trimmed
Content-TypeAccepted
application/jsonYes — the ordinary path, up to the server's 2 MB body limit
text/plainYes, when the body is JSON. This is what navigator.sendBeacon(url, aString) sends: a beacon cannot set a header
application/x-www-form-urlencodedYes, when the body is JSON. Not parsed as form fields — send JSON, not a=1&b=2
absent or blankYes, when the body is JSON
anything elseYes, when the body is JSON — except image/*, multipart/* and application/octet-stream, which belong to media uploads

Bodies that are not application/json are capped at 64 KB — the browser's own sendBeacon ceiling — and are only parsed when the body looks like JSON (it starts with { or [). A form-encoded a=1&b=2 body is still counted as wrong_content_type.

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST https://simplypim.co.uk/v1/events \
  -H "Content-Type: application/json" \
  -d '{
        "site_id": "northlight-av",
        "type": "add_to_basket",
        "sku": "SONY-NPFZ100",
        "session_id": "s-9f31c2",
        "payload": {"qty": 2, "from": "accessory-widget"}
      }'
# 204
204 response
HTTP/1.1 204 No Content
(no body — by design)

This endpoint answers 204 to everything, including your mistakes. A renamed field, a type that is not in the list, a truncated beacon, an insert that fails — all of them get an empty 204. That is deliberate: telemetry runs on a retailer's product page, and a 4xx there is a console error, a failed request in someone's monitoring, and a support ticket about your shop. It also means you cannot tell a broken integration from a working one by the status code, so two things exist for that and neither changes the contract: ?debug=1 while you wire the snippet up, and GET /v1/events/stats afterwards.

Wiring it up: ?debug=1

Add ?debug=1 to a single post and it answers 200 with what it made of your body, instead of the silent 204. It is a per-request developer aid:

BehaviourDetail
Still not an error200, never a 4xx — a 204 cannot carry a body, and a 4xx is the exact thing this endpoint promises never to send. Check ok, not the status
Not a dry runAn accepted event is stored and the attempt is counted like any other. event_id is the row it wrote
Per requestOnly the post that carries the flag. ?debug=0 is off, and so is an unrelated query string
Never used by the pixelpublic/pixel.js posts to a bare /v1/events and does not contain the string — a test asserts it stays that way. Nothing you install turns this on for real traffic
curl -s -X POST "https://simplypim.co.uk/v1/events?debug=1" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "northlight-av", "type": "purchase", "sku": "SONY-FX3"}'
200 response — rejected
{
  "ok": false,
  "stored": false,
  "outcome": "rejected",
  "reason": "unknown_type",
  "site_id": "northlight-av",
  "event_id": null,
  "errors": [
    {
      "field": "type",
      "message": "Invalid enum value. Expected 'view' | 'widget_impression' | 'widget_click' | 'add_to_basket', received 'purchase'",
      "code": "invalid_enum_value"
    }
  ],
  "valid_types": ["view", "widget_impression", "widget_click", "add_to_basket"],
  "note": "debug=1 is an opt-in wiring aid and changes only this response, never the behaviour: the attempt was counted like any other, and an accepted event really is stored — this is not a dry run. Without it POST /v1/events answers 204 with an empty body and never reports an error; public/pixel.js never sets it. Totals: GET /v1/events/stats."
}
200 response — accepted, but unattributable
{
  "ok": true,
  "stored": true,
  "outcome": "accepted",
  "reason": "unknown_sku",
  "site_id": "northlight-av",
  "event_id": 39,
  "errors": [
    {
      "field": "sku",
      "message": "stored, but 'NOT-IN-THE-PIM' is not in the catalogue — this event cannot be attributed to a product"
    }
  ],
  "valid_types": ["view", "widget_impression", "widget_click", "add_to_basket"],
  "note": "debug=1 is an opt-in wiring aid and changes only this response, never the behaviour: the attempt was counted like any other, and an accepted event really is stored — this is not a dry run. Without it POST /v1/events answers 204 with an empty body and never reports an error; public/pixel.js never sets it. Totals: GET /v1/events/stats."
}

A body that never parsed reports reason: "malformed_body" with the parser's own message and site_id: null — nothing can attribute a body it could not read. A genuine form post (a=1&b=2) reports wrong_content_type.

Is it working? GET /v1/events/stats

GET/v1/events/statsread

Accepted versus rejected ingest for the last days UTC days, with the reason breakdown and a per-site split. This is the endpoint that makes a broken pixel visible: rejections are answered 204 and stored nowhere, so this is the only place they are ever reported. Unlike POST /v1/events it needs a key — it is an operator's report about your install, not a storefront call.

QueryNotes
daysDefault 7, max 365. 0, 9999 or a non-number is a 400 validation_error rather than a silently invented window
curl -s "https://simplypim.co.uk/v1/events/stats?days=7" \
  -H "X-API-Key: $SIMPLYPIM_KEY"
200 response (series trimmed)
{
  "days": 7,
  "from": "2026-07-24",
  "to": "2026-07-30",
  "totals": {
    "accepted": 11,
    "rejected": 14,
    "unattributed": 2,
    "attempts": 25,
    "rejection_rate": 0.56
  },
  "reasons": {
    "missing_site_id": 3,
    "unknown_type": 7,
    "malformed_body": 3,
    "wrong_content_type": 1,
    "write_failed": 0
  },
  "accepted_reasons": { "unknown_sku": 2 },
  "series": [
    { "day": "2026-07-24", "accepted": 0, "rejected": 0, "unattributed": 0, "reasons": {} },
    { "day": "2026-07-29", "accepted": 0, "rejected": 0, "unattributed": 0, "reasons": {} },
    {
      "day": "2026-07-30",
      "accepted": 11,
      "rejected": 14,
      "unattributed": 2,
      "reasons": { "malformed_body": 3, "missing_site_id": 3, "unknown_type": 7, "wrong_content_type": 1 }
    }
  ],
  "sites": [
    {
      "site_id": "northlight-av",
      "accepted": 10,
      "rejected": 1,
      "unattributed": 1,
      "reasons": { "unknown_type": 1 },
      "last_at": "2026-07-30 03:09:29"
    },
    {
      "site_id": "hifi-corner",
      "accepted": 1,
      "rejected": 8,
      "unattributed": 1,
      "reasons": { "malformed_body": 2, "unknown_type": 6 },
      "last_at": "2026-07-30 03:09:29"
    },
    {
      "site_id": "",
      "accepted": 0,
      "rejected": 5,
      "unattributed": 0,
      "reasons": { "malformed_body": 1, "missing_site_id": 3, "wrong_content_type": 1 },
      "last_at": "2026-07-30 03:09:29"
    }
  ],
  "sites_truncated": false,
  "notes": [
    "`rejected` events were answered 204 and stored nowhere — POST /v1/events never returns an error, so a rejection is only ever visible here. Add ?debug=1 to a single post to see the validation errors for that request.",
    "`unattributed` is the slice of `accepted` whose SKU is not in the catalogue: the row WAS stored, but nothing can tie it to a product, so the suggestion miner and the behavioural recommendation tier both ignore it. It is not added to `rejected`.",
    "site_id \"\" means the payload carried no usable site_id, or never parsed as JSON at all — a body that fails to parse cannot be attributed to a site."
  ]
}

That is a real install with two storefronts on it: northlight-av is healthy, and hifi-corner is sending type: "pageview" — 8 of its 9 posts are on the floor. site_id: "" is not a bug either; it is where bodies land that never parsed, or that carried no usable site_id, because a body you cannot read cannot be attributed to anyone.

FieldMeans
totals.attemptsaccepted + rejected. Every post that reached the route
totals.rejection_raterejected / attempts, 3 dp, 0 when nothing was posted at all
totals.unattributedThe slice of accepted whose SKU is not in your catalogue. Not added to rejected — the row was stored
reasonsZero-filled across the whole vocabulary, so one response tells you every reason that exists
seriesOne entry per UTC day, ascending, ending today. Quiet days are present as zeros rather than missing
sitesStrongest first, capped at 25 with sites_truncated. A response is a report, not a dump
last_atUTC timestamp of that site's most recent counted attempt — how you spot a pixel that stopped
ReasonWhat the sender did
missing_site_idNo site_id, or a blank one. Nothing can be attributed, so this is first in precedence
unknown_typetype is missing, or is not one of the four. A missing type is reported here rather than as a sixth reason — to the developer wiring the snippet both mean "the type field is wrong"
malformed_bodyAnything else the schema refused: a missing or oversized sku, a bad session_id, a JSON array, or a body that tried to be JSON and failed to parse
wrong_content_typeThe body never became JSON at all and never claimed to be — a real form post, an opaque blob, plain prose
write_failedOur fault, not yours. The event validated and the insert failed. Normally 0; anything else is a disk or database problem on the install — tell us at hello@simplypim.co.uk and we will investigate
unknown_skuAn accept, not a rejection. It appears under accepted_reasons and as unattributed: the event is stored, but nothing can tie it to a product, so the miner and the behavioural recommendation tier both ignore it

This is an aggregate, not a log of rejected events, and that is on purpose. One counter row per (day, site, outcome, reason) — never a row per event. A misconfigured pixel inside a render loop can post thousands of identical failures a minute, and a per-event rejection log on the one endpoint that needs no API key would be an unbounded table that anyone on the internet can write to: a denial-of-service with extra steps. So you get counts and reasons here, and the individual validation errors from ?debug=1 on a request you make yourself. Both queries behind this response GROUP BY in SQL, so its size is bounded by days-or-sites × outcomes × reasons and never by your event volume.

401 no key — the storefront route is open, this one is not
{
  "error": "unauthorized",
  "message": "Missing or invalid x-api-key header"
}
400 ?days=0
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "days", "message": "Number must be greater than or equal to 1" }
  ]
}

Counting can never break ingest: it is a lazily-prepared write inside a total try/catch, so an install whose counter table is missing degrades to "no counting" and this endpoint reports zeros with a 200 — the 204 on POST /v1/events and the event row itself are never at risk. integrations.events_rejection in the diagnostics drives the whole contract end to end.

Bulk import

One call, up to 500 products, matched by SKU: existing rows are updated, new SKUs are created, and every row reports its own outcome. This is the API import path — no file upload, no mapping UI, no waiting.

POST/v1/products/bulkimport (or write)

Send {"products": [ … ]}. Each row takes everything POST /v1/products takes, plus nested attributes, prices, stock and media so a product arrives complete in one hop. Rows with no sku are created with a generated ID.

BehaviourDetail
MatchingBy sku. Found → update (partial, only the fields you send); not found → create
IsolationEvery row is its own transaction: a bad row rolls itself back and the rest of the batch still commits
StatusAlways 200 when the request itself is well-formed — check errors and the per-row action
Cap500 rows per request (400 bad_request above that). Chunk larger imports and run them in sequence
ValidationIdentical to the single-product routes: unknown category, family, attribute code, price line or stock location fails that row with its own message
curl -s -X POST https://simplypim.co.uk/v1/products/bulk \
  -H "X-API-Key: $SIMPLYPIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"products": [
        { "sku": "SONY-FX3",
          "name": "Sony FX3 Cinema Line Camera",
          "status": "live",
          "prices": [{ "price_list": "web-uk", "amount": 3799.00 }] },

        { "sku": "NLAV-TEST-1",
          "name": "Northlight Test Cage",
          "brand": "Northlight",
          "category": "camera-support",
          "status": "live",
          "attributes": { "color": "Black", "max_load_kg": { "value": 8, "unit": "kg" } },
          "prices": [{ "price_list": "web-uk", "amount": 129.99, "cost": 74.50, "rrp": 149.99 }],
          "stock":  [{ "location": "NEW-LON", "qty": 12, "allocated": 2 }] },

        { "name": "Auto-SKU Widget", "brand": "Northlight", "status": "draft" },

        { "sku": "NLAV-TEST-3", "name": "Bad price list",
          "prices": [{ "price_list": "does-not-exist", "amount": 10 }] }
      ]}'
200 response
{
  "count": 4,
  "created": 2,
  "updated": 1,
  "errors": 1,
  "results": [
    { "index": 0, "sku": "SONY-FX3",    "action": "updated", "id": 1 },
    { "index": 1, "sku": "NLAV-TEST-1", "action": "created", "id": 33,
      "warnings": ["'NLAV-TEST-1' does not match the global SKU convention '{BRAND:3}-{SEQ}'"] },
    { "index": 2, "sku": "NOR-0001",    "action": "created", "id": 34 },
    { "index": 3, "sku": "NLAV-TEST-3", "action": "error",
      "error": "Price list 'does-not-exist' not found",
      "error_code": "not_found" }
  ]
}

index lines a result up with the row you sent. A created row that used a generated SKU reports it (NOR-0001), and a manual SKU that does not match its convention comes back with a warnings array — non-blocking, the row still saved.

Manage API keys

These routes are session-authed: they need the pim_session cookie you get from POST /v1/auth/login (or by signing in to the dashboard). An X-API-Key cannot mint, edit or revoke keys — keys never escalate themselves.

POST/v1/api-keyssession
FieldNotes
name requiredWhat it is for — “Storefront search”, “Orderwise sync”
scopesAny of read, write, search, import. Defaults to ["read"]
allowed_originsUp to 20 entries. Empty = no origin restriction
allowed_ipsUp to 20 addresses or CIDR blocks. Empty = no IP restriction
rate_limit_per_minInteger, or null (default) for no limit
# Sign in once, keep the cookie
curl -s -X POST https://simplypim.co.uk/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "…"}' \
  -c cookies.txt

curl -s -X POST https://simplypim.co.uk/v1/api-keys \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Storefront search",
        "scopes": ["search"],
        "allowed_origins": ["https://shop.example.com", "*.northlight.co.uk"],
        "rate_limit_per_min": 60
      }'
201 response
{
  "api_key": {
    "id": 2,
    "name": "Storefront search",
    "key_prefix": "spim_8a3033",
    "masked": "spim_8a3033_…",
    "kind": "publishable",
    "scopes": ["search"],
    "allowed_origins": ["https://shop.example.com", "*.northlight.co.uk"],
    "allowed_ips": [],
    "rate_limit_per_min": 60,
    "last_used_at": null,
    "created_at": "2026-07-29 22:40:46",
    "revoked_at": null,
    "revoked": false,
    "usage": { "today": 0, "last_30_days": 0 }
  },
  "key": "spim_8a3033__4VkJhuhWcZDYPsH1jfDij1IEaMEVWm3",
  "message": "Copy this key now — it cannot be shown again."
}
GET/v1/api-keyssession

Your keys, newest first, revoked ones last (add ?include_revoked=false to hide them). Secrets are never returned — key_prefix and masked are for display, and usage counts every request the key made, denials included.

curl -s https://simplypim.co.uk/v1/api-keys -b cookies.txt
200 response
{
  "api_keys": [
    {
      "id": 2,
      "name": "Storefront search",
      "key_prefix": "spim_8a3033",
      "masked": "spim_8a3033_…",
      "kind": "publishable",
      "scopes": ["search"],
      "allowed_origins": ["https://shop.example.com", "*.northlight.co.uk"],
      "allowed_ips": [],
      "rate_limit_per_min": 60,
      "last_used_at": "2026-07-29 22:41:48",
      "created_at": "2026-07-29 22:40:46",
      "revoked_at": null,
      "revoked": false,
      "usage": { "today": 6, "last_30_days": 6 }
    },
    {
      "id": 1,
      "name": "Backoffice sync",
      "key_prefix": "spim_fbb618",
      "masked": "spim_fbb618_…",
      "kind": "secret",
      "scopes": ["read", "write", "import"],
      "allowed_origins": [],
      "allowed_ips": [],
      "rate_limit_per_min": 600,
      "last_used_at": "2026-07-29 22:41:48",
      "created_at": "2026-07-29 22:40:46",
      "revoked_at": null,
      "revoked": false,
      "usage": { "today": 2, "last_30_days": 2 }
    }
  ],
  "available_scopes": ["read", "write", "search", "import"]
}

GET /v1/api-keys/:id returns one key in the same shape as { "api_key": … }.

PATCH/v1/api-keys/:idsession

Change the name, scopes, origin/IP allowlists or rate limit. The secret is untouched, so a live integration keeps working. Lowering a rate limit resets the current window. Editing a revoked key is 409 conflict.

curl -s -X PATCH https://simplypim.co.uk/v1/api-keys/1 \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"name": "Backoffice sync", "rate_limit_per_min": 600}'
200 response (trimmed)
{
  "api_key": {
    "id": 1,
    "name": "Backoffice sync",
    "key_prefix": "spim_fbb618",
    "kind": "secret",
    "scopes": ["read", "write", "import"],
    "rate_limit_per_min": 600,
    "revoked": false,
    "usage": { "today": 2, "last_30_days": 2 }
  }
}
POST/v1/api-keys/:id/revealsession

Shows the secret again (2026-08-27). SimplyPIM keeps a sealed copy of every key minted from that date, so losing the value no longer means rotating and reconfiguring everything that used it. Your own keys only, signed in, and every reveal is written to the audit log — which is why it is a POST rather than a GET.

curl -s -X POST https://simplypim.co.uk/v1/api-keys/3/reveal -b cookies.txt
200 response
{
  "key": "spim_8ec7a3_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe",
  "at_rest": "encrypted",
  "api_key": { "id": 3, "name": "Rate test", "can_reveal": true, "secret_at_rest": "encrypted" }
}

at_rest: "plaintext" means credential encryption is not enabled, so the sealed copy is stored readable and a database dump contains working keys. Contact support to enable encryption, then rotate to seal them. 409 comes back for a revoked key, and for a key minted before this existed — those were only ever hashed, so there is nothing to show and the message says to rotate.

POST/v1/api-keys/:id/rotatesession

Issues a new secret for the same key — id, name, scopes, restrictions and usage history all stay. The old value stops working immediately (401 invalid_api_key), so deploy the new one first if you cannot take a gap.

curl -s -X POST https://simplypim.co.uk/v1/api-keys/3/rotate -b cookies.txt
200 response (trimmed)
{
  "api_key": {
    "id": 3,
    "name": "Rate test",
    "key_prefix": "spim_8ec7a3",
    "masked": "spim_8ec7a3_…",
    "kind": "secret",
    "scopes": ["read"],
    "allowed_ips": ["127.0.0.1", "10.0.0.0/8", "::1"],
    "rate_limit_per_min": 3,
    "last_used_at": null,
    "revoked": false
  },
  "key": "spim_8ec7a3_zK8t…",
  "message": "Copy this key now — it cannot be shown again. The previous secret no longer works."
}
DELETE/v1/api-keys/:idsession

Revokes the key. The row survives (so its usage history and audit trail do too) and the secret is refused for ever with 401 key_revoked. Revoking is idempotent.

curl -s -X DELETE https://simplypim.co.uk/v1/api-keys/4 -b cookies.txt
200 response (trimmed)
{
  "api_key": {
    "id": 4,
    "name": "IP locked",
    "key_prefix": "spim_c41d7e",
    "revoked": true,
    "revoked_at": "2026-07-29 22:42:17",
    "usage": { "today": 1, "last_30_days": 1 }
  }
}
401 using it afterwards
{ "error": "key_revoked", "message": "This API key has been revoked" }

Diagnostics & system check

A built-in test system that answers one question honestly: is this install actually working? It is written for whoever is responsible for keeping the thing up — 47 checks across 9 groups that do real work against the live database and configuration and report what they found, with a fix for anything they do not like.

Three rules make it safe to point at production:

  • Read-only by default. A check may only write if it declares so, and such a check is skipped unless you opt in. You can run the whole registry against a live catalogue and know nothing was touched.
  • No check reports pass without verifying the thing. "The table exists" is not a pass for "the index is consistent" — the check recomputes, re-queries or re-runs and compares.
  • Every warn and fail carries a remedy. An admin standing in front of a broken install needs the next command, not a diagnosis.

A check can never break the endpoint: the runner catches anything that escapes a probe and turns it into a fail, so one bad check cannot 500 the request or abort the run.

Status vocabulary

StatusMeansWhat to do
passVerified working. Never carries a remedyNothing
warnWorking, but not as it should be — a stale exchange rate, a missing API key, a development-only setting still on in productionRead the remedy. A deploy gate does not fail on these
failBroken. The admin must actAct. This is what turns the CLI's exit code non-zero
skipNot applicable to this install, or not opted into (writes / live AI)Nothing. The only status allowed to carry neither a remedy nor an assertion

Warn versus fail is a deliberate line, not a severity guess. An empty catalogue warns — a fresh install is not broken, it is empty. A missing ANTHROPIC_API_KEY warns — every AI feature falls back to deterministic output, which works, just differently. A corrupt index or a server that will not boot fails. That is why warn is safe to tolerate in CI and fail is not.

GET/v1/diagnosticsread

The registry — every check, grouped and in display order — plus the state of the write lock and the last run's full results, so an admin screen can render without running anything.

FieldNotes
groups[]{ group, label, description, checks[] }; each check is { id, label, writes, live }
check_countHow many checks the registry holds. Read this rather than hard-coding a number — checks get added
write_lock{ held, run_id, held_for_ms } — see the write self-test
last_runThe cached run, or null before anything has run this process
curl -s "https://simplypim.co.uk/v1/diagnostics" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response (one group expanded, before any run)
{
  "groups": [
    {
      "group": "core",
      "label": "Core",
      "description": "Database handle, schema, migrations and the search index shape.",
      "checks": [
        { "id": "core.db_open",       "label": "Database opens, WAL mode, integrity", "writes": false, "live": false },
        { "id": "core.schema_tables", "label": "All expected tables present",          "writes": false, "live": false },
        { "id": "core.migrations",    "label": "Migrations applied",                   "writes": false, "live": false },
        { "id": "core.fts_shape",     "label": "Search index shape and row count",     "writes": false, "live": false },
        { "id": "core.seed_presence", "label": "Catalogue and vocabulary present",     "writes": false, "live": false }
      ]
    }
  ],
  "check_count": 47,
  "write_lock": { "held": false, "run_id": null, "held_for_ms": null },
  "last_run": null
}

GET /v1/diagnostics/groups returns just the group names and the same nested detail, for a caller that only wants the vocabulary.

The nine groups

Order matters and is the display order: Core runs first because a Core failure explains most of what follows, and the write self-test runs last so it operates on a database the read-only checks have already described.

GroupChecksCovers
core5Database handle, WAL mode and integrity, every expected table, migrations applied, and the search index's shape and row count
catalogue7Product data integrity: orphaned attributes, category mirrors, master links, stock agreement
search4The FTS index and query parser actually returning the right products for known queries
pricing6Exchange rates, base currency, normalisation, and the landed-cost and margin arithmetic
enrichment4API key and model configuration, and the offline fallback path. Includes the one live check
integrations7API keys, the pixel, event ingest, recommendations and the suggestion miner
security5Sessions, the owner account, credential leaks in served assets, and route gating
ops8Disk, database and WAL size, query latency, the health endpoint, static assets
writes1The opt-in end-to-end write self-test

Check ids are always <group>.<name>core.fts_shape, ops.server_boot — and they are stable, so ?checks= and a UI's keys keep working.

Running the checks

POST/v1/diagnostics/runread

Runs the registry, or a subset of it, and returns every result. A read-only run is available to any authenticated caller — that is the shape a deploy smoke gate wants and it changes nothing.

FieldNotes
groupsGroup names — an array, or a comma-separated string. Omit for all
checksIndividual check ids, same two forms. Combines with groups as a union
include_writesowner Also run the write self-test
include_live_aiowner Also make one real Anthropic API call
curl -s -X POST "https://simplypim.co.uk/v1/diagnostics/run" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe" \
  -H "Content-Type: application/json" \
  -d '{ "groups": ["core", "search"] }'
200 response (two groups; results trimmed to two entries)
{
  "started_at": "2026-07-30T01:38:15.715Z",
  "finished_at": "2026-07-30T01:38:15.739Z",
  "summary": { "pass": 9, "warn": 0, "fail": 0, "skipped": 0, "total": 9, "duration_ms": 24 },
  "options": {
    "groups": ["core", "search"],
    "checks": null,
    "include_live_ai": false,
    "include_writes": false
  },
  "groups": [
    { "group": "core",   "label": "Core",   "pass": 5, "warn": 0, "fail": 0, "skipped": 0 },
    { "group": "search", "label": "Search", "pass": 4, "warn": 0, "fail": 0, "skipped": 0 }
  ],
  "results": [
    {
      "id": "core.db_open",
      "group": "core",
      "label": "Database opens, WAL mode, integrity",
      "ms": 7.9,
      "status": "pass",
      "detail": "open at /srv/simplypim/data/pim.db (632 KB), journal_mode=wal, foreign_keys=on, busy_timeout=5000ms, quick_check ok",
      "data": {
        "db_path": "/srv/simplypim/data/pim.db",
        "journal_mode": "wal",
        "foreign_keys": true,
        "busy_timeout_ms": 5000,
        "integrity": "ok"
      }
    },
    {
      "id": "core.fts_shape",
      "group": "core",
      "label": "Search index shape and row count",
      "ms": 0.3,
      "status": "pass",
      "detail": "shape [sku, name, brand, description, attrs_flat, category, keywords] correct; 37 indexed rows for 37 products",
      "data": {
        "columns": ["sku", "name", "brand", "description", "attrs_flat", "category", "keywords"],
        "products": 37,
        "indexed": 37
      }
    }
  ]
}
Result fieldNotes
id, group, labelWhich check this is
statuspass / warn / fail / skip
detailOne line an admin can read. Numbers over adjectives
msWall-clock time for this check, to one decimal
remedyPresent on every warn and fail, and never on a pass
dataOptional structured payload — per-step lists, counts, hashes — for a UI that wants more than the sentence

A whole read-only run on a healthy seeded install looks like this — note that the four skips are the opt-in checks, not failures:

200 {} — every check
{
  "summary": { "pass": 38, "warn": 5, "fail": 0, "skipped": 4, "total": 47, "duration_ms": 44 },
  "groups": [
    { "group": "core",         "label": "Core",              "pass": 5, "warn": 0, "fail": 0, "skipped": 0 },
    { "group": "catalogue",    "label": "Catalogue",         "pass": 6, "warn": 1, "fail": 0, "skipped": 0 },
    { "group": "search",       "label": "Search",            "pass": 4, "warn": 0, "fail": 0, "skipped": 0 },
    { "group": "pricing",      "label": "Pricing/FX",        "pass": 6, "warn": 0, "fail": 0, "skipped": 0 },
    { "group": "enrichment",   "label": "Enrichment/AI",     "pass": 2, "warn": 1, "fail": 0, "skipped": 1 },
    { "group": "integrations", "label": "Integrations",      "pass": 4, "warn": 1, "fail": 0, "skipped": 2 },
    { "group": "security",     "label": "Accounts/security", "pass": 3, "warn": 2, "fail": 0, "skipped": 0 },
    { "group": "ops",          "label": "Ops",               "pass": 8, "warn": 0, "fail": 0, "skipped": 0 },
    { "group": "writes",       "label": "Write self-test",   "pass": 0, "warn": 0, "fail": 0, "skipped": 1 }
  ]
}
the warns from that run, with their remedies
WARN catalogue.orphan_attributes
     every attribute is grouped, but 1 attribute belongs to no family and no attribute set: show_on_web
  →  Add each to an attribute set (POST /v1/attribute-sets/:code/attributes) or a family, or delete
     it — a field in neither can never be asked for on a product form.

WARN enrichment.api_key
     ANTHROPIC_API_KEY is not set — every AI feature is running on the deterministic fallback
  →  Set ANTHROPIC_API_KEY in .env (or the environment) and restart to enable AI enrichment, AI
     keywords and the price-suggestion narrative. The install works without it; the output is
     template-based rather than written.

WARN integrations.events_ingest
     34 events stored but none in the last 30 days (latest 2026-06-25 01:39:23)
  →  The pixel has stopped reporting. Check the snippet is still on the storefront and that
     POST /v1/events is reachable from it.

WARN security.owner_account
     no user accounts exist, so there is no owner and the dashboard cannot be signed into
  →  Create the first account at /signup/ — it becomes the owner (or set PIM_OWNER_EMAIL to
     nominate a specific address).

WARN security.route_gating
     11 gating assertions correct; unauthenticated GET /v1/products and /v1/diagnostics both refused
     with 401 — PIM_REQUIRE_PUBLIC_KEY is off, so /v1/search, /v1/recommendations and /v1/events are
     open to anonymous callers; PIM_API_KEY is still the built-in development default
  →  For a production install: set PIM_API_KEY to a long random value, and set
     PIM_REQUIRE_PUBLIC_KEY=true so the storefront must present a domain-locked publishable key.

A typo in a group or check name is a fail, not a silent narrowing. A mistyped name in a CI invocation would otherwise quietly reduce your coverage to nothing while still exiting 0, so it is reported as a failing pseudo-check:

{
  "id": "unknown.group.typo",
  "group": "core",
  "label": "Unknown group 'typo'",
  "status": "fail",
  "detail": "'typo' is not a check group, so nothing was run for it",
  "remedy": "Use one of: core, catalogue, search, pricing, enrichment, integrations, security, ops, writes.",
  "ms": 0
}

What is owner-gated, and why

All four routes sit behind the normal /v1 rule — an API key or a session. On top of that:

ThingWhoWhy
A read-only run, and GET /v1/diagnosticsAny authenticated callerIt changes nothing, and a deploy gate needs it
include_writesOwnerIt writes to the catalogue, however carefully
include_live_aiOwnerIt spends the install's money
The support bundleOwner, unconditionallyIt is an install-wide description — environment fingerprint, every table count, the migration list. Redacted, but not an ordinary customer's business

The environment API key always passes, so scripts and CI work. A browser session passes only when it belongs to the owner — an ordinary signed-in account gets 403, and the message names what it was refused:

403 an editor session asking for writes
{ "error": "forbidden", "message": "Owner access is required for the write self-test" }
403 …for a live AI call, and for both at once
{ "error": "forbidden", "message": "Owner access is required for a live AI call" }
{ "error": "forbidden", "message": "Owner access is required for the write self-test and a live AI call" }

The same session running a read-only subset gets a plain 200 — the gate is on the two opt-ins, not on diagnostics as a whole.

The write self-test

One check, opt-in and owner-gated, that drives the entire write pipeline end to end and then proves it left nothing behind. It creates two scratch products under a __diagnostics category and walks 14 steps: category → create → attributes → price → stock → unit → relation → keyword → enrichment → a search that must find the scratch product → insights → a CSV export row → delete → zero residue.

Why it is safe on a production catalogue, precisely:

  • Every row it creates is named __DIAG-… or __diagnostics, so anything it somehow left is identifiable at a glance and removable with one statement.
  • SKUs are always explicit, never generated, so your SKU sequence is not consumed — a generated SKU would permanently shift your numbering. The counter is snapshotted and asserted unchanged.
  • It reads your existing vocabulary (a price list, a stock location, two attributes) rather than inventing its own wherever it can; anything it did have to create is tracked and removed.
  • The scratch products sit on the install's default status and are never publicly visible. The search step queries an internal any-status scope, so a real storefront query cannot see a scratch product even for the milliseconds it exists.
  • Cleanup runs regardless of which step failed, so an assertion failure in the middle still tidies up.
  • Residue is proven two ways: targeted queries for every table that could hold a reference, and a full before/after row-count diff of every user table — which catches a table the check has never heard of.
curl -s -X POST "https://simplypim.co.uk/v1/diagnostics/run" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe" \
  -H "Content-Type: application/json" \
  -d '{ "groups": ["writes"], "include_writes": true }'
200 response
{
  "summary": { "pass": 1, "warn": 0, "fail": 0, "skipped": 0, "total": 1, "duration_ms": 18 },
  "options": { "groups": ["writes"], "checks": null, "include_live_ai": false, "include_writes": true },
  "results": [
    {
      "id": "writes.self_test",
      "group": "writes",
      "label": "End-to-end write self-test on a scratch SKU",
      "ms": 17.5,
      "status": "pass",
      "detail": "all 14 steps passed in 15ms on scratch SKUs __DIAG-1785375591584cxwh-A/__DIAG-1785375591584cxwh-B: category → create → attributes → price → stock → unit → relation → keyword → enrich (offline) → search finds it → insights → export row → delete → zero residue. Zero residue: every one of 43 table row counts is back to its starting value.",
      "data": {
        "run_id": "1785375591584-9jquq4",
        "lock_stolen": false,
        "swept_on_entry": [],
        "scratch_skus": ["__DIAG-1785375591584cxwh-A", "__DIAG-1785375591584cxwh-B"],
        "scratch_category": "__diagnostics",
        "price_list_used": "web-uk",
        "stock_location_used": "NEW-LON",
        "created_vocabulary": { "attributes": [], "price_list": null, "stock_location": null },
        "steps": [
          { "step": "category",        "ok": true, "ms": 0.2, "detail": "__diagnostics created (id 13)" },
          { "step": "create",          "ok": true, "ms": 2.3, "detail": "__DIAG-…-A (id 38), FTS row present, primary category mirrored, status 'draft'" },
          { "step": "attributes",      "ok": true, "ms": 0.7, "detail": "2 values stored on focal_length_mm, max_aperture, indexed into attrs_flat" },
          { "step": "price",           "ok": true, "ms": 0.6, "detail": "web-uk set to 1234.56 (cost 900) → amount_minor 123456, margin 27.1%, 2 history rows" },
          { "step": "stock",           "ok": true, "ms": 0.4, "detail": "NEW-LON qty 3, allocated 1, free 2, 1 history row" },
          { "step": "unit",            "ok": true, "ms": 1.7, "detail": "1 unit (__DIAG-SERIAL-1, grade A) at NEW-LON; stock recomputed from units to qty 1" },
          { "step": "relation",        "ok": true, "ms": 1.3, "detail": "__DIAG-…-A --accessory_of--> __DIAG-…-B, readable from the parent" },
          { "step": "keyword",         "ok": true, "ms": 0.2, "detail": "stored and indexed into the keywords column" },
          { "step": "enrich (offline)", "ok": true, "ms": 3.1, "detail": "depth 'standard', fallback true (no_api_key), 2 attributes + 6 auto-keywords, completeness 75 → 90" },
          { "step": "search finds it", "ok": true, "ms": 1.0, "detail": "found by keyword (1 hit) and by SKU (2 hits), both on the internal any-status scope" },
          { "step": "insights",        "ok": true, "ms": 0.4, "detail": "2 price-history entries, 2 stock-history entries, current price present, completeness 90" },
          { "step": "export row",      "ok": true, "ms": 0.9, "detail": "2 planned rows, 3 CSV lines (header + data), 9 columns" },
          { "step": "delete",          "ok": true, "ms": 0.7, "detail": "both deleted; the cascade cleared 7 dependent tables and both index rows" },
          { "step": "zero residue",    "ok": true, "ms": 1.3, "detail": "no rows left behind; all 43 table counts identical to before the run; SKU sequence untouched" }
        ],
        "table_count_drift": [],
        "residue": []
      }
    }
  ]
}

Two runs cannot overlap

The self-test takes an in-process lock. A second run while one is in flight is refused rather than allowed to interleave two sets of scratch rows — and the refusal is a fail with the lock's state attached, so you can see who holds it:

200 the second concurrent run's result
{
  "id": "writes.self_test",
  "status": "fail",
  "detail": "another write self-test has been running for 0s (run 1785375620937-53a8lz) — refusing to run two at once",
  "remedy": "Wait for it to finish. A crashed run's lock is released automatically after 5 minutes.",
  "data": {
    "lock": { "held": true, "run_id": "1785375620937-53a8lz", "held_for_ms": 1 }
  }
}

A lock older than 5 minutes is assumed dead — a process killed mid-test cannot wedge the feature for ever — and the next run steals it, reporting lock_stolen: true. GET /v1/diagnostics exposes the same state in its write_lock field, so a UI can disable the button before anyone clicks it.

The support bundle

One download that describes the whole install, for attaching to a support conversation. Owner only, in JSON or plain text, and served as an attachment.

RouteReturns
GET /v1/diagnostics/report.jsonThe bundle as JSON, Content-Disposition: attachment
GET /v1/diagnostics/report.txtThe same bundle rendered as plain text, for pasting into an email
QueryNotes
fresh1/true forces a new read-only run instead of reusing the cached one. Writes and live AI are never implied here
curl -s -OJ "https://simplypim.co.uk/v1/diagnostics/report.json?fresh=1" \
  -H "X-API-Key: spim_fbb618_QJ_93DuuUzc2yVbbUrr5AiBMLa75DBIe"
200 response headers
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-disposition: attachment; filename="simplypim-diagnostics-2026-07-30.json"
200 the bundle (each section abridged)
{
  "generated_at": "2026-07-30T01:40:39.150Z",
  "summary": { "pass": 39, "warn": 4, "fail": 0, "skipped": 4, "total": 47, "duration_ms": 25 },
  "run": {
    "started_at": "2026-07-30T01:40:39.125Z",
    "finished_at": "2026-07-30T01:40:39.150Z",
    "options": { "groups": null, "checks": null, "include_live_ai": false, "include_writes": false }
  },
  "results": [ /* every check result, exactly as POST /run returns them */ ],
  "groups":  [ /* the per-group tallies */ ],
  "environment": {
    "reported": {
      "NODE_ENV": null,
      "PORT": "4100",
      "PIM_DB": "/srv/simplypim/data/pim.db",
      "PIM_ENRICH_MODEL": null,
      "PIM_FX_PROVIDER": null,
      "PIM_REQUIRE_PUBLIC_KEY": null,
      "TZ": null
    },
    "secrets": {
      "ANTHROPIC_API_KEY": { "present": false, "chars": 0, "sha256_12": null },
      "PIM_API_KEY":       { "present": false, "chars": 0, "sha256_12": null },
      "SESSION_SECRET":    { "present": false, "chars": 0, "sha256_12": null }
    },
    "database_url_shape": null,
    "dotenv_present": false
  },
  "versions": {
    "simplypim": "0.1.0",
    "node": "v24.14.0",
    "platform": "darwin-arm64",
    "sqlite": "3.53.4",
    "fastify": "^5.10.0",
    "better-sqlite3": "^13.0.2"
  },
  "database": {
    "path": "/srv/simplypim/data/pim.db",
    "bytes": 663552,
    "wal_bytes": 4144752,
    "table_counts": { "products": 37, "prices": 93, "events": 34, "…": 0 }
  },
  "migrations": { "applied": [ /* 35 schema-shape markers */ ], "missing": [] },
  "dashboard": {
    "shell_present": true,
    "assets": [
      { "file": "index-walchPIU.js", "bytes": 305559, "sha256_16": "2bc90a1bd5b6bbdc" }
    ]
  },
  "redaction_leaks": []
}

Secrets are never in the bundle — and it checks its own work. A secret-looking variable is reported only as { present, chars, sha256_12 }: whether it is set, how long it is, and 12 characters of a hash so two installs can be compared without either value being revealed. Anything named like a key, secret, token, password, credential, DSN or URL is redacted by default, so a variable added later is covered without anyone remembering to list it.

redaction_leaks is the self-test, and it is always []. If a secret ever survived the scrub, the download is refused with a 500 redaction_failed and no bundle content — leaking a credential is worse than failing a download. The text report ends with the same verdict in words:

Redaction self-test: PASS — no secret value appears anywhere in this bundle.
200 report.txt — the head of the plain-text form
================================================================================================
SimplyPIM system diagnostics — support bundle
================================================================================================
generated   2026-07-30T01:42:14.284Z
run         2026-07-30T01:42:14.260Z → 2026-07-30T01:42:14.284Z (24ms)
options     groups=all checks=all include_writes=false include_live_ai=false
result      0 FAIL, 4 WARN, 39 PASS, 4 SKIP of 47

GROUPS
------------------------------------------------------------------------------------------------
  Core                   5 pass    0 warn    0 fail    0 skip
  Catalogue              6 pass    1 warn    0 fail    0 skip
  Search                 4 pass    0 warn    0 fail    0 skip
  Pricing/FX             6 pass    0 warn    0 fail    0 skip
  Enrichment/AI          2 pass    1 warn    0 fail    1 skip
  Integrations           4 pass    1 warn    0 fail    2 skip
  Accounts/security      4 pass    1 warn    0 fail    0 skip
  Ops                    8 pass    0 warn    0 fail    0 skip
  Write self-test        0 pass    0 warn    0 fail    1 skip
403 a non-owner session
{
  "error": "forbidden",
  "message": "Your role 'editor' cannot read the diagnostics report — 'owner' is required.",
  "role": "editor",
  "required_role": "owner"
}

Operators: the same checks also run headless on the server itself, as a deploy gate with the same verdicts and the same bundle. That CLI is part of running a SimplyPIM install rather than using its API, so it is documented with the server — everything a caller of this API needs is the three endpoints above.

OpenAPI, SDK & Postman

Everything on this page is also available as a machine-readable OpenAPI 3.1 description, served from your own install and needing no credential — an API description is not a secret, and a code generator cannot present one:

GET /openapi.json
curl -s https://simplypim.co.uk/openapi.json | jq '.info.version, (.paths | length)'

It is generated from the running server, not written by hand: the operation list comes from Fastify's own router, every request body and query schema from the module's real zod object, and every response schema and example from live responses off a freshly seeded install. Two extension fields on each operation say who may call it — x-simplypim-required-role (owner > editor > viewer, see Authentication) and x-simplypim-required-scopes (see Scopes) — and both are computed by calling the server's own guard functions, so they cannot drift from behaviour.

Point any spec-aware tool at it: Swagger UI, Redoc, Stoplight, an openapi-generator client in your language, or a contract test.

TypeScript SDK

A typed client is generated from the same description — one namespace per resource, one method per operation, zero runtime dependencies (plain fetch):

TypeScript
import { SimplyPim, NotFoundError } from '@simplypim/sdk';

const pim = new SimplyPim({ baseUrl: 'https://simplypim.co.uk', apiKey: process.env.PIM_KEY });

// Typed filters, typed results — every Advanced find parameter is a field.
const page = await pim.products.list({ brand: ['Sony'], status: ['live'], stock: 'in', limit: 20 });

// Pagination without an offset loop.
for await (const product of pim.products.listAll({ status: ['live'] })) {
  console.log(product.sku, product.completeness);
}

// One call for a product with its prices, stock, attributes, media and relations.
const fx3 = await pim.products.get('SONY-FX3');

try {
  await pim.products.get('NOPE');
} catch (error) {
  if (error instanceof NotFoundError) { /* 404, typed */ }
}

Also in the box: API-key and session-cookie auth, one typed error class per status (Errors) with the API's stable error code on it, and a webhook signature verifier. The package is not on the public npm registry yet — email hello@simplypim.co.uk and we will send it over. Prefer another language, or your own client? Point any OpenAPI generator at /openapi.json and it emits one.

Postman collection

Postman reads the OpenAPI description directly — Import → Linkhttps://simplypim.co.uk/openapi.json gives you a collection with every endpoint, its parameters and a request-body example. Add your key once as collection-level API Key auth (header name X-API-Key) and every request is ready to send.

MCP — agents & Claude

SimplyPIM ships a Model Context Protocol server, so Claude and other agents can read and write the catalogue directly with your existing keys and roles. Two transports:

TransportUse it when
stdioOperators only — a desktop client running on the same machine as the server process itself. As a customer you connect over HTTP.
Streamable HTTPPOST /mcpA hosted agent, which cannot spawn a process inside your install. Connects with a URL and an X-API-Key.
GET /mcp/info
curl -s https://simplypim.co.uk/mcp/info

The endpoint is JSON-RPC 2.0, not a REST resource, which is why it sits outside /v1: one POST to /v1/mcp would read as a write and be refused before any tool had been named. Authorisation happens per tool instead — the layer that knows what a call will actually do — under the same scopes and roles as the REST API, and every write lands in the audit log.

An agent is sent whatever its key is allowed to be sent. The key's field_exposure list narrows the tool payloads exactly as it narrows that key's HTTP responses — so a copywriting agent given a key limited to name, description, keywords and attributes gets those fields from get_product and no prices, partners or stock block at all. Cost prices and supplier codes never enter the model's context in the first place, which is a stronger guarantee than asking it not to repeat them.

GET /mcp/tools
curl -s https://simplypim.co.uk/mcp/tools | jq '.count, .tools[0].name'

The full tool catalogue — every tool with the scope and the role its key needs — is served by the install itself at GET /mcp/tools, and rendered in the dashboard under Integrations → AI agents (MCP) together with paste-ready client configuration and guidance on choosing the narrowest key. It is generated from the running server, so it can never disagree with what a call will actually be allowed to do. tools/list is reachable without a credential on purpose — a client has to be able to discover what exists before it can be told it may not use it — while every tools/call is checked.

Errors

Every failure has the same shape — an error code, a message written for a human, and details[] when a specific field is at fault:

400 validation error
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "details": [
    { "path": "name", "message": "String must contain at least 1 character(s)" }
  ]
}
StatuserrorMeaning
400bad_request / validation_errorMalformed input; details[] points at the field
401unauthorizedNo credential on a protected route
401invalid_api_keyKey not recognised (wrong, deleted or rotated away)
401key_revokedThe key was revoked
401invalid_credentialsSign-in failed. Identical for a wrong password and an unknown email — deliberately, so the route cannot enumerate accounts
403insufficient_scopeValid key, wrong scope — the body lists required_scopes
403origin_not_allowedOrigin/Referer is not on the key's allowlist
403ip_not_allowedCaller IP is not on the key's allowlist
403forbiddenSigned in, but not allowed here — a key on the key-management routes, or a non-owner on an owner-gated route. Role refusals add role and required_role
404not_foundUnknown SKU, category, attribute, attribute set, family, price line, location, status, condition, unit, suggestion, saved view or route
404plan_not_foundUnknown plan_id on POST /v1/account/subscribe
402plan_limit_reachedAn active subscription's product ceiling, enforced inside the products module. The body carries limit and count. A child line counts as a product
409plan_limit_reachedThe same wall via the opt-in enforce_plan_limits gate. Same error string, so match on that rather than on the status
409email_takenSignup with an email that already has an account
409key_quota_exceededYou hold as many live keys as api_key_quota_per_user allows; details carries quota and count
409conflictDuplicate SKU, a duplicate serial, a SKU collision the convention could not resolve, unit-derived stock, a second master on one child, or editing a revoked key
429rate_limitedPer-key limit; retry after retry_after seconds
500internal_errorOur fault. The response never leaks internals; the server logs carry a request id
500redaction_failedThe support bundle failed its own secret-redaction self-test and was withheld rather than shipped

Vocabulary errors tell you the vocabulary. Because statuses and conditions are yours to define, a bad code is a 400 bad_request that lists the valid ones and where to add more — e.g. Unknown condition 'mint'. Valid conditions: new, used, demo, showroom (define more at POST /v1/conditions). The same applies on writes and on filters, so a client can surface the message directly instead of hard-coding an enum.

Limits & conventions

ThingLimit
Request body2 MB
Bulk import500 products per request
Product list pagelimit max 200 (default 20)
CSV exportlimit max 50,000 (the hard ceiling too); 100 columns
Search pagelimit max 100 (default 20); q max 300 characters
Advanced findq max 200 characters; 50 values per list filter; 200 category_ids
Completeness filter / sort20,000 candidate rows. completeness_gte/completeness_lte and sort=completeness are the one thing that cannot be paged in SQL — the filtered set is scored in the application, and only the first 20,000 rows matching your other filters are considered. Narrow with a category or status filter on a catalogue larger than that, or the tail is invisible to the completeness pass
Attributes per write500 values; tags up to 500 entries
Attribute sets200 members per set; 200 categories per assignment; code 64 characters, label 120, description 2,000
Prices / stock / media / relations per write50 / 100 / 50 / 200 rows
Units per write500 per request, all-or-nothing; serial 120 characters, notes 2,000
InheritanceOne level, one master; 200 inherit tokens per call
Keywords50 manual, 15 auto, 15 suggestions per call
Saved views / prefs200 views per user; 32 KB per filters/sort/columns blob and per pref value; 500 prefs listed
Exchange ratesConsidered stale after 24 hours; markup_pct −100 to 1000
Insights windowdays clamped to 1–730; 2,000 history rows and 500 competitor captures per response
Relation suggestionslimit max 200 (default 50); 90-day mining window; a session touching more than 40 distinct SKUs is discarded as a crawler
Diagnostics47 checks (read check_count, do not hard-code it); one write self-test at a time, its lock going stale after 5 minutes
Per key20 origins, 20 IP entries, your chosen requests/minute; api_key_quota_per_user live keys per person (default 20)
Sessionspim_session valid 30 days; password 8 characters minimum
  • Dates are ISO (YYYY-MM-DD); timestamps are UTC YYYY-MM-DD HH:MM:SS.
  • Money is in major units on the wire (129.99) and minor units in storage, so rounding never drifts. Three places speak minor units explicitly, and say so in their name or their block: the price_gte/price_lte filters, amount_minor, and everything inside margin and normalized.
  • Codes — attribute codes are lower_snake_case; category, price-line, family, status and condition codes are lower-kebab-case. A code is immutable once anything stores it: change the label instead.
  • Versioning — response shapes only ever gain keys within /v1; anything breaking would arrive as /v2.
  • CORS is open (*) with Content-Type and X-API-Key allowed, so browser calls work; protect keys with the origin allowlist rather than with CORS.