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:
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"
const res = await fetch('https://simplypim.co.uk/v1/products?limit=2&status=live', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { count, results } = await res.json();
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.
| Situation | What happens |
|---|---|
| Valid key, allowed by its scopes and restrictions | 200 — the call runs and is counted against the key |
No key on /v1/search, /v1/recommendations/*, works-with | 401 unauthorized — they require a key by default since 2026-08-27 (see below) |
No key on POST /v1/events | 200 — the telemetry sink stays open (it discloses nothing) |
| No key anywhere else | 401 unauthorized |
| Unknown or rotated-away key | 401 invalid_api_key |
| Revoked key | 401 key_revoked |
| Key without the scope the route needs | 403 insufficient_scope |
| Key used from a domain or IP it is not allowed on | 403 origin_not_allowed / 403 ip_not_allowed |
| Key over its per-minute limit | 429 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:
| Kind | Looks like | Use 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.
| Scope | Opens |
|---|---|
read | Every catalogue GET: products, categories, attributes, families, price lists, stock locations, SKU conventions, price suggestions… |
write | Every mutation: POST/PATCH/PUT/DELETE on products, categories, attributes, prices, stock, relations, enrichment |
search | GET /v1/search, GET /v1/recommendations/*, POST /v1/events — the storefront trio |
import | POST /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 }
]
}
| Token | Means |
|---|---|
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. |
attributes | The 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_valuesblocks — the flattening only happens where a payload carries no such block. - A field with no value is left out rather than sent as
"", sohit.web_product_idisundefinedexactly 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
| Value | Meaning |
|---|---|
null (default) | The install's own ceilings — 100 on search, 200 on the product list. |
| a number, 1–10000 | The 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:
| Entry | Matches |
|---|---|
https://shop.example.com | Exactly that scheme + host (+ port when non-default) |
shop.example.com | That host on any scheme or port |
localhost:3000 | That host and port — handy while developing |
*.example.com | example.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.
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.
| Control | Effect |
|---|---|
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
/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.
| Field | Notes |
|---|---|
email required | Normalised to lower case and trimmed; unique across the install |
password required | 8 characters minimum. Stored as scrypt with a per-user salt — never recoverable, only resettable |
name required | The person |
company required | The business. Omitting it is a 400 validation_error on company |
phone | Optional, 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"
}'
const res = await fetch('/v1/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// the browser stores the pim_session cookie for you
credentials: 'same-origin',
body: JSON.stringify({
email: 'ops@northlightcameras.co.uk',
password: 'correct-horse-battery',
name: 'Dana Okoro',
company: 'Northlight Cameras',
phone: '+44 20 7946 0812',
}),
});
const { user, trial, access } = await res.json();
{
"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
}
set-cookie: pim_session=2b64ba3ec91e417201607d4e7d0f4319…; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax
{ "error": "email_taken", "message": "An account with that email already exists" }
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.
/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" }'
const res = await fetch('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
email: 'ops@northlightcameras.co.uk',
password: 'correct-horse-battery',
}),
});
if (res.status === 401) showError('Incorrect email or password');
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.
/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
const res = await fetch('/v1/auth/me', { credentials: 'same-origin' });
if (res.status === 401) return redirectToLogin();
const { user, trial, subscription, access } = await res.json();
if (!access) return showTrialExpired(trial);
{
"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
}
{ "error": "unauthorized", "message": "Sign in to continue" }
| Field | Meaning |
|---|---|
trial.ends_at | UTC timestamp, 14 days after signup |
trial.days_left | Counts 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.active | true while ends_at is in the future |
subscription | The active subscription, else the outstanding pending request, else null. Cancelled history is never surfaced |
access | The one to gate on: trial.active or a subscription whose status is active |
/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
await fetch('/v1/auth/logout', { method: 'POST', credentials: 'same-origin' });
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
| Attribute | Value | Why |
|---|---|---|
HttpOnly | set | JavaScript cannot read it, so an XSS bug cannot exfiltrate the session |
SameSite | Lax | Sent on top-level navigation, withheld on cross-site sub-requests |
Path | / | Covers the API and the dashboard |
Max-Age | 2592000 | 30 days. The server-side row expires at the same moment, so an old cookie cannot be replayed |
Secure | not set | So 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.
/v1/plansopenThe 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"
const { plans } = await (await fetch('/v1/plans')).json();
plans.forEach((plan) => renderCard(plan.name, plan.price_monthly_gbp, plan.features));
{
"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"
]
}
]
}
/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.
| Field | Notes |
|---|---|
plan_id required | An 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" }'
const res = await fetch('/v1/account/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ plan_id: 'growth' }),
});
const { subscription } = await res.json();
// subscription.status === 'pending' — tell the customer you will be in touch.
{
"subscription": {
"plan_id": "growth",
"plan_name": "Growth",
"product_limit": 10000,
"price_monthly_gbp": 149,
"status": "pending",
"started_at": "2026-07-30 01:39:14"
}
}
{ "error": "plan_not_found", "message": "Plan 'enterprise' not found" }
status | Means |
|---|---|
pending | Requested. No access granted; the owner has been handed a to-do |
active | The owner activated it after invoicing. This is what keeps access true once the trial lapses |
canceled | Superseded 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.
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"
}
| Route | Gated? |
|---|---|
POST /v1/products | Yes — 409 plan_limit_reached |
POST /v1/products/:sku/lines | Yes — a child line is a product, and counts as one |
POST /v1/products/bulk | No — 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 / DELETE | No — 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.
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 }
}
quota block on GET /v1/api-keys{ "quota": { "limit": 1, "used": 1 } }
Usage
/v1/account/usagereadEverything 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.
| Field | Notes |
|---|---|
products.count | Every product row, child lines included |
products.limit | The active subscription's ceiling, or null. Trials are unlimited, so a trialling account reads null |
plan | null until a subscription is active — a pending request does not appear here |
api_calls_daily | 30 entries, ascending, zero-filled. Counts every /v1 request including refused ones |
events_daily | 30 entries, ascending, zero-filled — pixel telemetry |
completeness | Mean 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"
const usage = await (await fetch('/v1/account/usage', {
credentials: 'same-origin',
})).json();
drawSparkline(usage.api_calls_daily.map((day) => day.count));
{
"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
/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
const res = await fetch('/v1/accounts', { credentials: 'same-origin' });
if (res.status === 403) return hideCustomersDesk();
const { accounts, role_counts } = await res.json();
{
"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 }
}
{
"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.
/v1/productsreadPaged list with a lightweight projection (price, free stock, primary image, completeness).
| Query | Notes |
|---|---|
status | A statuses.code — draft, live, discontinued and archived out of the box, plus any you define. CSV or repeated |
condition | A conditions.code — new, used, demo, showroom out of the box. CSV or repeated |
category | Category slug or id; includes the category's direct children, and matches any membership (not just the primary one) |
brand | Case-insensitive exact match. CSV or repeated |
family | Family code, e.g. camera |
q | Substring match on SKU, name and brand, widened by the same term expansion /v1/search uses. For ranked results and facets use /v1/search |
sort / order | name | sku | updated | completeness | price, and asc (default) | desc |
limit / offset | Default 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"
const params = new URLSearchParams({ limit: '2', status: 'live' });
const res = await fetch(`https://simplypim.co.uk/v1/products?${params}`, {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { count, results } = await res.json();
{
"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.
/v1/products/:skureadThe 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"
const res = await fetch('https://simplypim.co.uk/v1/products/SONY-FX3', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
if (res.status === 404) throw new Error('unknown SKU');
const product = await res.json();
{
"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.
/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).
| Field | Notes |
|---|---|
name required | Up to 300 characters |
sku | Up to 64 characters, unique. Omit to auto-generate |
brand, description, raw_source_text | Free text. raw_source_text is supplier copy for enrichment |
family | Family code — drives the attribute schema and completeness |
category | Primary category by slug |
category_ids + primary_category_id | Full multi-category assignment by id |
status | Any statuses.code — draft, 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 |
condition | Any conditions.code — new (the column default), used, demo, showroom out of the box, plus your own |
keywords | Up 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_tracked | true makes stock the sum of the product's units. Turning it back off is refused while units exist |
master / master_id | The 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."
}'
const res = await fetch('https://simplypim.co.uk/v1/products', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
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.',
}),
});
const product = await res.json(); // 201, or 409 when the SKU exists
{
"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.
/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}'
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'live', category_ids: [9, 3], primary_category_id: 9 }),
});
{
"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
}
/v1/products/:skuwriteDeletes 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
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1', {
method: 'DELETE',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
console.log(res.status); // 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:
{
"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.
/v1/products/:sku/mediawritecurl -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}
]}'
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
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 },
],
}),
});
{
"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.
/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-Type | Where the bytes and the metadata go |
|---|---|
multipart/form-data | Bytes 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-streamor 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 |
| Field | Notes |
|---|---|
file required | Multipart only — the bytes. The other two shapes carry the bytes in the body itself |
alt | Alt 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 |
kind | Up to 32 characters, defaults to "image". Free-form, so "lifestyle" or "dimension-drawing" are yours to define |
position | 0-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 |
filename | Recorded 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')\"}"
// 1 — multipart/form-data, straight from an <input type="file">
const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('alt', 'Nucleus Lite hand unit, three-quarter view');
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media', {
method: 'POST',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY }, // do NOT set Content-Type
body: form,
});
const { deduped, item, media } = await res.json();
console.log(res.status, deduped); // 201 false — or 200 true when the bytes were already there
// 2 — raw bytes
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media?filename=motor-detail.png', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/octet-stream',
},
body: await file.arrayBuffer(),
});
// 3 — JSON with base64
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: 'lens-mount.png',
alt: '0.8 mod gear ring on the lens',
content_base64: Buffer.from(bytes).toString('base64'),
}),
});
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.
{
"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.
{
"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.
{
"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 — 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.
// 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.
{
"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"
}
}
{
"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_supported | variants_note | Meaning |
|---|---|---|
true | null | Variants 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 |
{
"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:
.png, stored as image/jpegcurl -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.
GET /media/p38/d11b3aae2b72/original.png — response headersHTTP/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
If-None-Matchcurl -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.
/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.
| Field | Notes |
|---|---|
alt | Up to 1000 characters, or null to clear |
position | Moves 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 |
kind | 1–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"}'
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media/40', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
alt: 'Motor mount detail, 0.8 mod gear ring engaged',
position: 0,
kind: 'image',
}),
});
{
"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 — 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" }
/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]}'
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media/order', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: [42, 39] }),
});
{
"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" /* … */ }
]
}
{ "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.
/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"
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/media/39', {
method: 'DELETE',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { removed_keys, media } = await res.json();
{
"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
]
}
{ "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.
/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.
| Field | Notes |
|---|---|
dry_run | true reports exactly what it would delete and deletes nothing. Always worth running first |
min_age_ms | Leave 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"
const res = await fetch('https://simplypim.co.uk/v1/media/gc', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ dry_run: true, min_age_ms: 300000 }),
});
const { deleted, bytes_reclaimed, note } = await res.json();
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)"
}
{
"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"
}
| Field | Meaning |
|---|---|
deleted | The keys removed — or, under dry_run, the keys that would be |
scanned | Files walked in the media directory |
skipped_recent | Unreferenced files left alone for being younger than min_age_ms |
truncated | true 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) |
directory | The 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.
/v1/media/configreadEverything 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"
const res = await fetch('https://simplypim.co.uk/v1/media/config', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const config = await res.json();
// Validate in the browser before spending the upload.
if (file.size > config.max_upload_bytes) reject('too big');
if (!config.allowed_content_types.includes(file.type)) reject('wrong type');
const willHaveThumbs = config.variants.resizable.includes(file.type);
{
"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 }
}
| Field | What to do with it |
|---|---|
max_upload_bytes | Check file size client-side against this and save the user a 15 MB round trip that ends in 413 |
allowed_content_types | Feed straight into an <input accept="…">. Remember the server sniffs anyway |
rejected_content_types | Each entry carries a reason you can show verbatim. SVG is the only entry as shipped |
variants.resizable | The formats that will actually get thumbnails on this install. passthrough is stored-but-not-resized |
variants_supported | Install-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_keys | true when this install's own keys embed a content hash, i.e. its media is served immutable |
accepts | The three request shapes, self-described |
counts | rows 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:
| Call | viewer | editor | owner |
|---|---|---|---|
GET /v1/media/config | 200 | 200 | 200 |
POST /v1/media/gc | 403 | 403 | 200 |
POST /v1/products/:sku/media and the PATCH/PUT/DELETE above | 403 | 200 | 200 |
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".
| Query | Form | Notes |
|---|---|---|
brand | list | Case-insensitive exact match, up to 50 values |
status | list | Any 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 |
condition | list | Any conditions.code, same 400 behaviour |
category | scalar | Slug or id. Matches the category plus its direct children, across every membership |
category_ids | list | Up to 200 ids, any membership. Unknown ids are dropped rather than refused |
include_descendants | boolean | Expands each category_ids entry to itself plus every descendant, recursively — not just one level, unlike category |
family / family_id | scalar | Family code / id. Independent of each other; both are applied if both are sent |
master / master_id | scalar | A 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_lte | scalar | 0–100 inclusive, fractions allowed |
stock | scalar | in (free > 0) or out (free ≤ 0). Anything else is a 400 |
stock_lt | scalar | Free stock below this integer — the reorder query. Composes with stock |
location_id | scalar | Scopes stock / stock_lt to one location. On its own it does nothing |
price_list | scalar | Which line the price filters and sort=price read. Defaults to web-uk |
price_gte / price_lte | scalar | MINOR units — price_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_active | boolean | true = only products whose deal window is open today; false = only those without one |
q | scalar | Up 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_before | scalar | ISO date or date-time (see below) |
updated_after / updated_before | scalar | Same — the "what changed since my last sync" pair |
sort | scalar | name, sku, updated, completeness or price. Anything else is a 400 |
order | scalar | asc (default) or desc |
limit / offset | scalar | 1–200, default 20 / ≥ 0, default 0. Out of range is a 400, not a silent clamp |
attr.<code> | either | Per-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.
/v1/productsreadSony 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"
const params = new URLSearchParams({
brand: 'Sony,Canon',
status: 'live',
'attr.mount': 'Sony E',
'attr.weight_g__lt': '1000',
completeness_gte: '80',
stock: 'in',
sort: 'price',
order: 'desc',
limit: '2',
});
const res = await fetch(`https://simplypim.co.uk/v1/products?${params}`, {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { count, results } = await res.json();
{
"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.
| Suffix | Means | Types | Several values |
|---|---|---|---|
(none) / __eq | Equals | all | OR |
__neq | Does not equal | all | Excludes any match |
__gt | Greater than | number, measure | Last wins |
__lt | Less than | number, measure | Last wins |
__gte | At least | number, measure | Last wins |
__lte | At most | number, measure | Last wins |
__contains | Substring — or whole-tag membership on tags | text, longtext, select, tags | OR |
__empty | No value stored (or blank) | all | value ignored |
__nempty | Some value stored | all | value ignored |
- Bare is equality.
attr.color=Blackisattr.color__eq=Black. Text,selectandlongtextcompare case- and whitespace-insensitively;number/measurecompare numerically (a non-numeric value is a400). - A repeated key is OR.
attr.color=black&attr.color=silverandattr.color=black,silverare the same query: either colour matches. Different codes are still AND-ed. tagsis membership, not substring.attr.uses=vlogmatches the product whoseuseslist containsvlog—__containsdoes exactly the same thing on atagsfield, so there is no way to match half a tag.__empty/__nemptyignore their value entirely.attr.mount__empty,attr.mount__empty=1andattr.mount__empty=falseall mean "no mount stored". To ask the opposite use__nempty—__empty=0does not invert.- Negation includes the unset.
__neqand__emptyboth match a product carrying no row for that attribute at all. Pair with__nemptywhen you need a stored value.
# 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
{ "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.
/v1/saved-viewssession| Field | Notes |
|---|---|
name required | 1–120 characters, trimmed |
filters | Any JSON object — the query you want replayed. Defaults to {} |
sort | Object, by convention {sort, order}. null clears it |
columns | Up to 200 strings. null clears it |
position | Integer ≥ 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"]
}'
const res = await fetch('/v1/saved-views', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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'],
}),
});
const view = await res.json(); // 201
{
"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.
/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"'
await fetch('/v1/me/prefs/products.columns', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: ['sku', 'name', 'brand', 'price', 'free_stock'] }),
});
const res = await fetch('/v1/me/prefs', { credentials: 'same-origin' });
const { prefs } = await res.json();
{
"key": "products.columns",
"value": ["sku", "name", "brand", "price", "free_stock"],
"updated_at": "2026-07-30 01:10:25"
}
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.
/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= token | Gives you |
|---|---|
sku name brand description | The identity fields. brand and description resolve through the master |
id status condition completeness | Status and condition are the plain codes; completeness is 0 when unscored |
family category categories | The family label, the primary category name, and all membership names joined with | |
price deal_price currency free_stock | The web-uk projection, major units. Empty cell when there is no price; free_stock is 0 |
created_at updated_at image | Timestamps 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
const params = new URLSearchParams({
brand: 'Sony',
columns: 'sku,name,attr.mount,price.web-uk,stock.NEW-LON,free_stock',
});
const res = await fetch(`https://simplypim.co.uk/v1/products/export.csv?${params}`, {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const rows = Number(res.headers.get('X-Total-Count'));
const csv = await res.text();
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.
{
"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).
/v1/categoriesreadFlat 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"
const res = await fetch('https://simplypim.co.uk/v1/categories', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { categories, tree } = await res.json();
{
"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": [] }
]
}
]
}
/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"}'
const res = await fetch('https://simplypim.co.uk/v1/categories', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: 'Cage Accessories', parent: 'camera-support' }),
});
const category = await res.json();
{
"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.
/v1/attributesreadcurl -s https://simplypim.co.uk/v1/attributes -H "X-API-Key: $SIMPLYPIM_KEY"
const res = await fetch('https://simplypim.co.uk/v1/attributes', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { attributes } = await res.json();
{
"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.
/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}'
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/attributes', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
color: 'Black',
max_load_kg: { value: 8, unit: 'kg' },
weather_sealed: true,
}),
});
const { applied, cleared, attributes } = await res.json();
{
"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.
| Family | Attribute set | |
|---|---|---|
| How many per product | One | Any number |
| Attached to | The product | A category — and inherited by its descendants |
| Answers | "What kind of thing is this?" | "What does anything filed here need?" |
| Good for | Camera, Lens, Battery | Shipping & compliance, Rental operations, Regional labelling |
/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"
const res = await fetch('https://simplypim.co.uk/v1/attribute-sets', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { attribute_sets } = await res.json();
{
"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.
/v1/attribute-setswriteCreates a set. Members and category assignments can both go in the same call, so a set is usable the moment it exists.
| Field | Notes |
|---|---|
code required | lower-kebab-case (underscores allowed), 64 characters. Immutable once created — change label instead |
label required | What a human sees, 120 characters |
description | Up to 2,000 characters. Shown on the set's card in the dashboard |
attributes | Up to 200 members. Each is either a bare attribute code or { code, required?, position? }; array order becomes position when you omit it |
categories | Up 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"]
}'
const res = await fetch('https://simplypim.co.uk/v1/attribute-sets', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
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'],
}),
});
const set = await res.json();
{
"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" }
]
}
{
"error": "validation_error",
"message": "Request body failed validation",
"details": [
{ "path": "code", "message": "code must be lower kebab/snake case" }
]
}
{ "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.
| Route | Does |
|---|---|
PUT /v1/attribute-sets/:ref/attributes | Replaces the member list; array order becomes position |
POST /v1/attribute-sets/:ref/attributes | Adds members, and re-toggles required/position on ones already there |
DELETE /v1/attribute-sets/:ref/attributes/:code | Removes one member. Answers with the set plus removed (a count) |
PATCH /v1/attribute-sets/:ref | Label, 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? }.
/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 }
] }'
await fetch(
'https://simplypim.co.uk/v1/attribute-sets/shipping-compliance/attributes',
{
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
attributes: [
{ code: 'battery_wh' },
{ code: 'commodity_code', required: true },
],
}),
},
);
[
{ "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 }
]
/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"] }'
await fetch('https://simplypim.co.uk/v1/attribute-sets/shipping-compliance', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
order: ['commodity_code', 'un_code', 'weight_g', 'box_dimensions_mm', 'battery_wh'],
}),
});
[
{ "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 }
]
/v1/attribute-sets/:ref/attributes/:codewriteRemoves 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"
await fetch(
'https://simplypim.co.uk/v1/attribute-sets/shipping-compliance/attributes/battery_wh',
{ method: 'DELETE', headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY } },
);
{
"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
}
/v1/attribute-sets/:refwriteDrops 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"
await fetch('https://simplypim.co.uk/v1/attribute-sets/scratch-set', {
method: 'DELETE',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
{
"id": 4,
"code": "scratch-set",
"deleted": true,
"attributes_removed": 0,
"categories_unassigned": 0
}
{ "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 set | Does |
|---|---|
PUT /v1/attribute-sets/:ref/categories | Replace the set's category list |
POST /v1/attribute-sets/:ref/categories | Assign more categories |
DELETE /v1/attribute-sets/:ref/categories/:category | Unassign one |
| From the category | Does |
|---|---|
GET /v1/categories/:id/attribute-sets | Its own sets and the ones it inherits |
PUT /v1/categories/:id/attribute-sets | Replace 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 }.
/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"
const res = await fetch(
'https://simplypim.co.uk/v1/categories/cine-cameras/attribute-sets',
{ headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY } },
);
const { sets, inherited } = await res.json();
{
"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
}
]
}
/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"] }'
await fetch('https://simplypim.co.uk/v1/categories/ssds', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ attribute_set_ids: ['shipping-compliance'] }),
});
{
"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:
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:
- the family first (required members before optional, then by code);
- then the sets of each assigned category, nearest first — the category itself, then its parent, then its grandparent;
- sets within a category by
code, and members within a set byposition.
Every entry carries where it came from:
source | Means |
|---|---|
family | The 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.
/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.
| Query | Notes |
|---|---|
family | A 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"
const res = await fetch(
'https://simplypim.co.uk/v1/categories/cine-cameras/schema?family=camera',
{ headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY } },
);
const { attributes, required } = await res.json();
// Render `attributes` in the order given; mark the ones in `required`.
{
"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
}
{
"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"]
}
{ "error": "not_found", "message": "Family 'nope' not found" }
/v1/products/:sku/schemareadThe 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"
const res = await fetch('https://simplypim.co.uk/v1/products/CANON-C70/schema', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { attributes, required, attribute_sets } = await res.json();
{
"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
scoreand itsmissing[]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_codeon 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:
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.
/v1/statusesreadEvery 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"
const res = await fetch('https://simplypim.co.uk/v1/statuses', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { statuses } = await res.json();
const publicCodes = statuses.filter((s) => s.visible_public).map((s) => s.code);
{
"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 } }
]
}
| Field | Meaning |
|---|---|
code | What every product stores. Immutable — renaming would orphan them, so change label instead |
label / color | Display only. color is a hex triplet/quad such as #16a34a |
position | Display order, 0–9999 |
visible_public | The behaviour flag. true = products with this status appear in search, recommendations and any storefront read |
is_default | The status a product created without one gets. Exactly one row carries it |
protected | Ours, not yours: the row cannot be deleted (relabel and recolour freely). Set by the system and rejected on write |
usage.products | How 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.
/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}'
const res = await fetch('https://simplypim.co.uk/v1/statuses', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'clearance', label: 'Clearance', color: '#dc2626', visible_public: true }),
});
const status = await res.json(); // 201
{
"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.
/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"
const res = await fetch('https://simplypim.co.uk/v1/statuses/clearance?reassign_to=live', {
method: 'DELETE',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { products_reassigned: moved } = await res.json();
{
"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.
/v1/conditionsreadcurl -s https://simplypim.co.uk/v1/conditions -H "X-API-Key: $SIMPLYPIM_KEY"
const res = await fetch('https://simplypim.co.uk/v1/conditions', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { conditions } = await res.json();
{
"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.
/v1/conditionswrite| Field | Notes |
|---|---|
code required | Lower kebab/snake case, up to 64 characters. Immutable afterwards |
label required | Up to 120 characters |
color | Hex triplet/quad, e.g. #0d9488 |
position | 0–9999; defaults to the end |
sku_token | 1–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"}'
const res = await fetch('https://simplypim.co.uk/v1/conditions', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'open-box', label: 'Open Box', color: '#0d9488', sku_token: 'OB' }),
});
const condition = await res.json(); // 201
{
"code": "open-box",
"label": "Open Box",
"color": "#0d9488",
"position": 4,
"sku_token": "OB",
"protected": false
}
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.
# 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.
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.
/v1/price-listsreadcurl -s https://simplypim.co.uk/v1/price-lists -H "X-API-Key: $SIMPLYPIM_KEY"
const res = await fetch('https://simplypim.co.uk/v1/price-lists', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { price_lists: priceLists } = await res.json();
{
"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.
/v1/products/:sku/priceswriteUpserts 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"}
}]}'
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/prices', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prices: [{
price_list: 'web-uk',
amount: 129.99,
cost: 74.5,
rrp: 149.99,
deal: { amount: 109.99, start: '2026-07-01', end: '2026-08-31' },
}],
}),
});
{
"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.
| Field | Notes |
|---|---|
duty_pct | Import duty as a percentage, 0–1000 |
shipping | Freight in, major units. Stored as shipping_minor |
other | Anything else — brokerage, handling. Stored as other_minor |
landed read-only | The result, major units. normalized.landed_minor is the same number in the base currency |
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.
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
| Key | Meaning |
|---|---|
value_minor | The cash the sale makes today, minor units: effective amount − cost. Can be negative |
pct | That 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_list | The 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.
{
"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
| Key | Meaning |
|---|---|
amount_minor, cost_minor, rrp_minor, deal_amount_minor, effective_amount_minor | The line's figures converted to the base currency, minor units. cost_minor is the margin basis — the landed value when there is one |
landed_minor | Cost lines only; always null on a sell line |
margin / margin_pct | The 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 |
currency | The base currency — what everything above is stated in, not the line's own currency |
rate | Quote 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_mode | base (identity), api, fixed, or mixed when a cross-rate's two legs disagree |
as_of | When that rate was fetched. null for the base identity and for a fixed rate |
stale | The 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.
/v1/settingsread| Key | Values | Default |
|---|---|---|
base_currency | A 3-letter ISO code that is a known, enabled currency with a usable rate | GBP |
enrichment_depth | minimal | standard | full — the default for enrichment runs that omit depth | standard |
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"
const res = await fetch('https://simplypim.co.uk/v1/settings?describe=1', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { settings, fields } = await res.json();
{
"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
}
}
?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."
}
]
}
| Key | Default | Does |
|---|---|---|
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_days | 90 | How long search_queries rows are kept. Pruned in the background, at most one sweep an hour — no cron job |
search_typo_fallback | true | Retry 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_user | 20 | Live keys one person may hold — see plan limits |
api_key_default_expiry_days | 0 | Expiry policy for newly minted keys that do not set their own. 0 means never expire. Existing keys are never changed |
enforce_plan_limits | false | Turn on the product ceiling (409 plan_limit_reached) |
product_limit | 0 | The 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.
/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"}'
await fetch('https://simplypim.co.uk/v1/settings', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ enrichment_depth: 'full' }),
});
{
"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
}
}
{ "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.
/v1/currenciesreadcurl -s https://simplypim.co.uk/v1/currencies -H "X-API-Key: $SIMPLYPIM_KEY"
const res = await fetch('https://simplypim.co.uk/v1/currencies', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { base, currencies } = await res.json();
{
"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.
| Key | Meaning |
|---|---|
mode | "api" — the provider's number, optionally marked up. "fixed" — a rate you typed, immune to the provider and to going stale |
api_rate | What the provider last returned. Kept and displayed even in fixed mode, so you can see what you are overriding |
fixed_rate | Your manual rate. null hands the pair back to the provider |
markup_pct | A spread on top of api_rate, −100 to 1000. Applies in api mode only |
effective_rate read-only | The 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_at | When we stored it, not the provider's quote date |
stale | true once fetched_at is more than stale_after_hours (24) old, or was never set. Always false in fixed mode |
source | Free 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.
/v1/exchange-ratesreadcurl -s https://simplypim.co.uk/v1/exchange-rates -H "X-API-Key: $SIMPLYPIM_KEY"
const res = await fetch('https://simplypim.co.uk/v1/exchange-rates', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { rates, stale_after_hours: maxAge } = await res.json();
const needsAttention = rates.filter((r) => r.stale);
{
"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.
/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}'
await fetch('https://simplypim.co.uk/v1/exchange-rates/USD', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ mode: 'fixed', fixed_rate: 1.25 }),
});
// hand it back to the provider later
await fetch('https://simplypim.co.uk/v1/exchange-rates/USD', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ mode: 'api', fixed_rate: null }),
});
{
"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"
}
{
"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.
/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"
const res = await fetch('https://simplypim.co.uk/v1/exchange-rates/refresh', {
method: 'POST',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { ok, updated, skipped, reason, rates } = await res.json();
if (!ok) console.warn(`rates not refreshed: ${reason}`);
{
"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 */ ]
}
| Key | Meaning |
|---|---|
ok | true when at least one rate was written |
updated / skipped | Quotes written / quotes the provider did not answer for. A skipped pair keeps its stored rate |
fetched_at / source | Our write time and a provenance string; null on a hard failure |
reason | Failure only. disabled, no_quotes, fetch_failed, http_error or invalid_payload, with a human message |
rates | Always 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
/v1/fx/convertread
The same conversion the normalized blocks use, exposed for prefilling a
price in another currency or sanity-checking a margin.
| Query | Notes |
|---|---|
amount_minor required | Integer minor units — 295000 is £2,950.00. Negatives allowed |
to required | 3-letter ISO code |
from | 3-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"
const params = new URLSearchParams({ amount_minor: '295000', to: 'EUR' });
const res = await fetch(`https://simplypim.co.uk/v1/fx/convert?${params}`, {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { amount_minor: converted, rate, stale } = await res.json();
{
"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
}
{
"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.
/v1/products/:sku/stockwriteUpserts 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}
]}'
await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/stock', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
stock: [
{ location: 'NEW-LON', qty: 24, allocated: 4 },
{ location: 'SHOWROOM', qty: 2 },
],
}),
});
{
"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.
| Field | Notes |
|---|---|
serial required | Up to 120 characters, unique within the product |
condition | Any conditions.code. Defaults to the product's own — and may legitimately differ from it: a showroom body under an otherwise used line is fine |
grade | Your own free-text grade — A, B+, Excellent |
location / location_id | Stock location by code or id. null for a unit held nowhere |
status | in_stock (default), allocated, sold or returned |
price_override / price_override_minor | This unit's own price — major or minor units, either is accepted, both are echoed. null means it takes the product's price |
acquired_at | YYYY-MM-DD or a full stamp. Defaults to now |
sold_at, notes | The 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.
/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"
const res = await fetch('https://simplypim.co.uk/v1/products/SONY-FX5-U/units?status=in_stock', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { units, units_summary: summary } = await res.json();
// "2 used from £2,995"
const { available_units: available, price_range: range } = summary;
{
"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_summary | Meaning |
|---|---|
total | Every unit, whatever its status. Note it is 10 above while count is 7 — the filter narrows units, never the summary |
by_condition | Every unit, keyed by condition code. Keys follow your vocabulary, so a new condition appears here automatically |
by_status | Every unit, keyed by the four unit statuses |
available_units | in_stock units per condition — "how many used ones can I actually sell" |
price_range | Cheapest 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.
/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"}
]}'
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-U/units', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
units: [
{ serial: 'NL-0001', grade: 'A', location: 'NEW-LON', price_override: 2495, acquired_at: '2026-07-02', notes: 'Boxed, 4,100 actuations.' },
{ serial: 'NL-0002', grade: 'B', location: 'NEW-LON', price_override: 2295, acquired_at: '2026-07-11' },
{ serial: 'NL-0003', grade: 'A', location: 'NEW-MCR', status: 'allocated', acquired_at: '2026-07-18' },
],
}),
});
const { created, units_summary: summary, stock } = await res.json(); // 201
{
"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.
# 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" }
{
"error": "validation_error",
"message": "Request body failed validation",
"details": [
{ "path": "units", "message": "Array must contain at most 500 element(s)" }
]
}
/v1/products/:sku/units/:idwriteAny 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."}'
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-U/units/26', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'sold', sold_at: '2026-07-29', notes: 'Sold with a 6 month warranty.' }),
});
const { unit, stock_totals: totals } = await res.json();
{
"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:
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 this | What happens |
|---|---|
Turn serial_tracked on | Existing manual levels are left alone and stay editable — nothing is derived until the first unit exists |
| Add the first unit | Levels are recomputed from the units. A location with no units drops to 0. PUT …/stock is now a 409 |
| Delete the last unit | The product is handed back to the plain path — PUT …/stock works again (its levels are already 0) |
Turn serial_tracked off while units exist | 409 — delete the units first |
{
"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
| Class | Fields | Behaviour |
|---|---|---|
| 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.
/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"}'
const res = await fetch('https://simplypim.co.uk/v1/products/SMALLRIG-CAGE-FX3/lines', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ condition: 'used' }),
});
const line = await res.json(); // 201 — inherits everything until you override it
{
"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.
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"]
}
}
| Key | What it gives you |
|---|---|
master | {id, sku, name} — the name is there so a badge can read "from Sony FX5" with no second call |
fields | Per inheritable core field: the resolved value plus source: "own" | "master" — exactly what you need to badge every input in an editor |
attributes | The same, per attribute code. Codes reading source: "own" are the overrides |
overridden_fields | The tokens this child owns, field order then attr. order. The master's children[] mirrors this exactly |
own | The 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:
GET /v1/products/SONY-FX5 → children[
{ "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 fields | Resolved | The child's own, null where it inherits |
resolved | Present (child only) | Absent |
master / children | Always reported | Always reported — children[] without overridden_fields |
completeness | The resolved score | Also 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"]}'
const res = await fetch('https://simplypim.co.uk/v1/products/SMALLRIG-CAGE-FX3-U', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ inherit: ['description', 'brand'] }),
});
const { resolved } = await res.json();
console.log(resolved.overridden_fields); // []
console.log(resolved.fields.description.source); // 'master'
{
"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 }
}
}
| Token | Clears |
|---|---|
description, brand, family | That column, so it resolves from the master |
keywords | Both keyword lists — manual and auto move together. auto_keywords is accepted as an alias |
media | The child's whole gallery, so the master's is shown again |
attr.<code> | One attribute's stored row |
attributes | Every own attribute row at once |
# 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.
/v1/products/:sku/relationswritecurl -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"}
]}'
await fetch('https://simplypim.co.uk/v1/products/SONY-FX3/relations', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
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' },
],
}),
});
{
"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.
/v1/relation-suggestions/minewriteRuns 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"
const res = await fetch('https://simplypim.co.uk/v1/relation-suggestions/mine', {
method: 'POST',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const result = await res.json();
{
"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
}
{
"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
}
| Counter | Means |
|---|---|
examined_events | view and add_to_basket events with a session id, inside the 90-day window |
examined_sessions | Distinct sessions that survived the bot guard |
pairs_scored | Pairs that cleared both thresholds — the candidate set before suppression |
suggestions_created | New rows in the inbox |
suggestions_updated | Existing pending rows whose score or evidence moved |
suggestions_unchanged | Pending rows the run agreed with exactly — no write happened |
skipped_existing_relation | Already linked in product_relations, either direction, any type — including by an earlier accept |
skipped_rejected | Held 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.
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.
| Threshold | Value | Why |
|---|---|---|
| Mining window | 90 days | Long enough for seasonal pairs, short enough to forget discontinued ones |
| Minimum sessions per pair | 3 | The session floor. Two shoppers is a coincidence, not a pattern — below this a pair is never worth a human's attention |
| Minimum lift | 1.2 | Below this the pair is no better than chance |
| Basket weight | 3× | An add_to_basket co-occurrence counts as three views |
| Bot guard | 40 SKUs | A 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.
/v1/relation-suggestionsreadThe inbox, strongest first. Defaults to pending — the queue of work.
| Query | Notes |
|---|---|
status | pending (default), accepted, rejected or all |
parent_sku | Only suggestions anchored on this product — the "what pairs with this?" panel |
limit | Default 50, max 200 |
offset | Standard 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"
const res = await fetch(
'https://simplypim.co.uk/v1/relation-suggestions?status=pending',
{ headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY } },
);
const { total, suggestions } = await res.json();
suggestions.forEach((s) => renderRow(s.parent.sku, s.child.sku, s.evidence_text));
{
"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.
/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"
const res = await fetch(
'https://simplypim.co.uk/v1/relation-suggestions/1/accept',
{ method: 'POST', headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY } },
);
const { suggestion, relation_created } = await res.json();
{
"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
}
{ "relation_created": false, "suggestion": { "status": "accepted", … } }
{ "error": "not_found", "message": "Relation suggestion 999 not found" }
/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"
await fetch('https://simplypim.co.uk/v1/relation-suggestions/2/reject', {
method: 'POST',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
{
"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.
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:
GET /v1/recommendations/accessories?sku=SONY-FX30 after the relation was replaced without ATOMOS-NINJA-VSONY-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.
/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}'
const res = await fetch('https://simplypim.co.uk/v1/skus/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ brand: 'Northlight', category: 'lenses', condition: 'used', preview: true }),
});
const { sku } = await res.json();
{
"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.
| Field | Who writes it | Cap |
|---|---|---|
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.
{
"sku": "SAMSUNG-T7-1TB",
"keywords": ["backup drive", "proxy storage", "offload"],
"auto_keywords": ["portable drive", "external drive", "usb-c ssd", "t 7"]
}
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"]
/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"
const res = await fetch('https://simplypim.co.uk/v1/products/SENN-MKE600/keywords/suggest', {
method: 'POST',
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { suggestions, existing } = await res.json();
// approve a subset — append, do not replace
await fetch('https://simplypim.co.uk/v1/products/SENN-MKE600', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ keywords: [...existing.keywords, 'shotgun mic', 'xlr'] }),
});
{
"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"
}
{
"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
/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 '{}'
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/enrich', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ fields: ['description', 'attributes'], overwrite: false }),
});
const report = await res.json();
{
"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 |
|---|---|---|---|
description | yes | yes | yes |
auto_keywords | yes | yes | yes |
attributes — the product's effective schema | — | yes | yes |
seo — seo_title, seo_description | — | yes | yes |
Returns relation_hints[] review only | — | — | yes |
Returns price_suggestion review only | — | — | yes |
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:
{"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"
}
{"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.
| Key | What 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.
/v1/products/:sku/price-suggestionreadAn 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"
const res = await fetch('https://simplypim.co.uk/v1/products/NLAV-DOCS-1/price-suggestion', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { suggested, rationale } = await res.json();
{
"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”.
/v1/products/:sku/insightsread| Query | Notes |
|---|---|
days | Window 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 |
| Key | What it is |
|---|---|
views | Pixel events bucketed by UTC day: views, clicks, basket_adds. Sparse — only days with activity appear |
price_history | Append-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_history | Append-only level changes per location with old/new qty, allocated and free |
price_stock_overlay | One 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_prices | The full capture log, newest first |
competitor_latest | The most recent capture per competitor — what you compare against today |
current | Today'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"
const res = await fetch('https://simplypim.co.uk/v1/products/SONY-FX3/insights?days=90', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { price_stock_overlay: overlay, competitor_latest: rivals, current } = await res.json();
// One point per day — safe to feed straight into a chart library.
const series = overlay.map((d) => ({ x: d.day, price: d.price, stock: d.free_stock, views: d.views }));
{
"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.
/v1/products/:sku/competitor-pricesreadcurl -s https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices \
-H "X-API-Key: $SIMPLYPIM_KEY"
const res = await fetch('https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { competitor_prices: captures } = await res.json();
{
"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" }
]
}
/v1/products/:sku/competitor-priceswrite| Field | Notes |
|---|---|
competitor required | Who you saw it at, up to 120 characters |
amount required | Major units, e.g. 2895.00 |
url | The listing you captured, so a merchandiser can re-check it |
currency | 3-letter ISO code; defaults to the product's sell currency |
captured_at | YYYY-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"
}'
const res = await fetch('https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
competitor: 'Park Cameras',
amount: 2895,
url: 'https://www.parkcameras.com/sony-fx3',
captured_at: '2026-07-29',
}),
});
const capture = await res.json(); // 201
{
"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.
/v1/products/:sku/competitor-prices/:idwritecurl -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
await fetch('https://simplypim.co.uk/v1/products/SONY-FX3/competitor-prices/5', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: 2879.99 }),
});
{
"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"
}
Search — natural language, no query DSL
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 list — web-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 asquery_click; what is in demand right now is ranked in astrending. Both are visible per result inmatched.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.
/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_…"
{
"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
}
| Query | Notes |
|---|---|
q required | What has been typed so far, up to 120 characters. Blank returns an empty list, never the catalogue. |
limit | Total 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
(panasnic → panasonic) 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".
/v1/searchsearch · key required by default| Query | Notes |
|---|---|
q required | Up to 300 characters. ?q= (blank) is a valid empty state: 200 with count: 0 rather than the whole catalogue |
limit / offset | Default 20, max 100 / default 0. count is the size of the whole filtered set, so paging is stable |
status | Defaults to live. any searches drafts and archived too (for internal tools) |
What the parser understands today, deterministically (no model call, no latency):
| Example query | Becomes |
|---|---|
256gb drive in stock under £200 | capacity_gb: 256, in_stock: true, price_max: 200, text “drive” |
sony camera over 3000 | brand: "Sony", price_min: 3000, text “camera” |
monitor around 300 | category: "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 monitor | category: "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 stock | color: "black", in_stock: true, text “cage” |
used fx3 | condition: "used", text “fx3” |
cfexpress type a | Pure 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"
const params = new URLSearchParams({ q: '256gb drive in stock under £200', limit: '20' });
const res = await fetch(`https://simplypim.co.uk/v1/search?${params}`, {
headers: { 'X-API-Key': 'spim_8a3033_…' }, // publishable key, locked to your domain
});
const { count, results, facets, parsed } = await res.json();
{
"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>
# In the dashboard: API → New key. Or, with your session cookie:
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
}'
# -> {"api_key": {...,"kind":"publishable"}, "key": "spim_8a3033_…"}
# Copy `key` now: it is never shown again.
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
/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.
| Query | Notes |
|---|---|
sku required | The product being viewed |
limit | Default 6, max 24 — it is a widget, not a listing. 25 is a 400 validation_error on limit |
include_oos | true keeps out-of-stock candidates |
curl -s "https://simplypim.co.uk/v1/recommendations/accessories?sku=SONY-FX3&limit=3"
const res = await fetch(
'https://simplypim.co.uk/v1/recommendations/accessories?sku=SONY-FX3&limit=3',
{ headers: { 'X-API-Key': PIM_PUBLISHABLE_KEY } },
);
const { items } = await res.json();
{
"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
-->
// Bespoke cart code? Tell the pixel yourself.
SimplyPIM.addToBasket('SONY-NPFZ100', { qty: 2 });
// Single-page app that swaps products without a reload:
SimplyPIM.track('view', 'SONY-FX30');
// data-basket-selector does this for you when the click target carries data-sku:
// <button class="add-to-basket" data-sku="SONY-NPFZ100">Add to basket</button>
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
/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.
| Body | Notes |
|---|---|
site_id required | Your site id, ≤ 120 chars. Stamped on the event and on the ingest counters, so this is what names you in the stats |
type required | One 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 |
payload | Any 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-Type | Accepted |
|---|---|
application/json | Yes — the ordinary path, up to the server's 2 MB body limit |
text/plain | Yes, when the body is JSON. This is what navigator.sendBeacon(url, aString) sends: a beacon cannot set a header |
application/x-www-form-urlencoded | Yes, when the body is JSON. Not parsed as form fields — send JSON, not a=1&b=2 |
| absent or blank | Yes, when the body is JSON |
| anything else | Yes, 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
const body = JSON.stringify({
site_id: 'northlight-av',
type: 'add_to_basket',
sku: 'SONY-NPFZ100',
session_id: sessionId,
payload: { qty: 2, from: 'accessory-widget' },
});
// Unload-time events (a basket click, a click-through, anything you fire from pagehide):
// sendBeacon is the only transport a browser promises to flush while the page is going away.
// Pass a STRING, not a Blob: a string goes out as text/plain, which is CORS-safelisted, so
// there is no preflight. A Blob typed application/json triggers one, and a beacon is always
// credentialed — which an API answering `Access-Control-Allow-Origin: *` can refuse.
if (!navigator.sendBeacon?.('https://simplypim.co.uk/v1/events', body)) {
// No sendBeacon, or the browser refused the queue. keepalive outlives the document.
await fetch('https://simplypim.co.uk/v1/events', {
method: 'POST',
keepalive: true,
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body,
});
}
// Ordinary in-page events, and the only form that can carry a publishable key:
await fetch('https://simplypim.co.uk/v1/events', {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-API-Key': PIM_PUBLISHABLE_KEY },
body: JSON.stringify({ site_id: 'northlight-av', type: 'view', sku: 'SONY-FX3', session_id: sessionId }),
});
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:
| Behaviour | Detail |
|---|---|
| Still not an error | 200, 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 run | An accepted event is stored and the attempt is counted like any other. event_id is the row it wrote |
| Per request | Only the post that carries the flag. ?debug=0 is off, and so is an unrelated query string |
| Never used by the pixel | public/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"}'
// Run this once from the page you are instrumenting, then delete it.
const res = await fetch('https://simplypim.co.uk/v1/events?debug=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site_id: 'northlight-av', type: 'purchase', sku: 'SONY-FX3' }),
});
const report = await res.json();
if (!report.ok) console.warn('SimplyPIM rejected this event:', report.reason, report.errors);
{
"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."
}
{
"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
/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.
| Query | Notes |
|---|---|
days | Default 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"
// Server-side only: this takes a secret key, never a publishable one.
const res = await fetch('https://simplypim.co.uk/v1/events/stats?days=7', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const stats = await res.json();
// The one number worth alerting on, per site.
for (const site of stats.sites) {
const attempts = site.accepted + site.rejected;
if (attempts > 20 && site.rejected / attempts > 0.2) {
console.warn(`${site.site_id || '(unattributable)'} is failing ingest`, site.reasons);
}
}
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.
| Field | Means |
|---|---|
totals.attempts | accepted + rejected. Every post that reached the route |
totals.rejection_rate | rejected / attempts, 3 dp, 0 when nothing was posted at all |
totals.unattributed | The slice of accepted whose SKU is not in your catalogue. Not added to rejected — the row was stored |
reasons | Zero-filled across the whole vocabulary, so one response tells you every reason that exists |
series | One entry per UTC day, ascending, ending today. Quiet days are present as zeros rather than missing |
sites | Strongest first, capped at 25 with sites_truncated. A response is a report, not a dump |
last_at | UTC timestamp of that site's most recent counted attempt — how you spot a pixel that stopped |
| Reason | What the sender did |
|---|---|
missing_site_id | No site_id, or a blank one. Nothing can be attributed, so this is first in precedence |
unknown_type | type 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_body | Anything 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_type | The body never became JSON at all and never claimed to be — a real form post, an opaque blob, plain prose |
write_failed | Our 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_sku | An 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.
{
"error": "unauthorized",
"message": "Missing or invalid x-api-key header"
}
?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.
/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.
| Behaviour | Detail |
|---|---|
| Matching | By sku. Found → update (partial, only the fields you send); not found → create |
| Isolation | Every row is its own transaction: a bad row rolls itself back and the rest of the batch still commits |
| Status | Always 200 when the request itself is well-formed — check errors and the per-row action |
| Cap | 500 rows per request (400 bad_request above that). Chunk larger imports and run them in sequence |
| Validation | Identical 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 }] }
]}'
async function importProducts(rows) {
const CHUNK = 500;
const failures = [];
for (let i = 0; i < rows.length; i += CHUNK) {
const res = await fetch('https://simplypim.co.uk/v1/products/bulk', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_IMPORT_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: rows.slice(i, i + CHUNK) }),
});
const report = await res.json();
console.log(`${report.created} created, ${report.updated} updated, ${report.errors} failed`);
failures.push(...report.results.filter((r) => r.action === 'error'));
}
return failures; // [{ index, sku, action: 'error', error, error_code }]
}
{
"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.
/v1/api-keyssession| Field | Notes |
|---|---|
name required | What it is for — “Storefront search”, “Orderwise sync” |
scopes | Any of read, write, search, import. Defaults to ["read"] |
allowed_origins | Up to 20 entries. Empty = no origin restriction |
allowed_ips | Up to 20 addresses or CIDR blocks. Empty = no IP restriction |
rate_limit_per_min | Integer, 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
}'
// From the dashboard (the session cookie travels automatically):
const res = await fetch('/v1/api-keys', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Storefront search',
scopes: ['search'],
allowed_origins: ['https://shop.example.com', '*.northlight.co.uk'],
rate_limit_per_min: 60,
}),
});
const { api_key: apiKey, key } = await res.json();
showOnce(key); // the ONLY time the raw key exists outside your own storage
{
"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."
}
/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
const res = await fetch('/v1/api-keys', { credentials: 'same-origin' });
const { api_keys: keys, available_scopes: scopes } = await res.json();
{
"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": … }.
/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}'
await fetch('/v1/api-keys/1', {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Backoffice sync', rate_limit_per_min: 600 }),
});
{
"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 }
}
}
/v1/api-keys/:id/revealsessionShows 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
const res = await fetch('/v1/api-keys/3/reveal', {
method: 'POST',
credentials: 'same-origin',
});
const { key, at_rest } = await res.json();
{
"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.
/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
const res = await fetch('/v1/api-keys/3/rotate', {
method: 'POST',
credentials: 'same-origin',
});
const { key } = await res.json(); // shown once
{
"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."
}
/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
await fetch('/v1/api-keys/4', { method: 'DELETE', credentials: 'same-origin' });
{
"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 }
}
}
{ "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
passwithout 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
warnandfailcarries aremedy. 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
| Status | Means | What to do |
|---|---|---|
pass | Verified working. Never carries a remedy | Nothing |
warn | Working, but not as it should be — a stale exchange rate, a missing API key, a development-only setting still on in production | Read the remedy. A deploy gate does not fail on these |
fail | Broken. The admin must act | Act. This is what turns the CLI's exit code non-zero |
skip | Not 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.
/v1/diagnosticsreadThe 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.
| Field | Notes |
|---|---|
groups[] | { group, label, description, checks[] }; each check is { id, label, writes, live } |
check_count | How 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_run | The 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"
const res = await fetch('https://simplypim.co.uk/v1/diagnostics', {
headers: { 'X-API-Key': process.env.SIMPLYPIM_KEY },
});
const { groups, check_count, write_lock, last_run } = await res.json();
{
"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.
| Group | Checks | Covers |
|---|---|---|
core | 5 | Database handle, WAL mode and integrity, every expected table, migrations applied, and the search index's shape and row count |
catalogue | 7 | Product data integrity: orphaned attributes, category mirrors, master links, stock agreement |
search | 4 | The FTS index and query parser actually returning the right products for known queries |
pricing | 6 | Exchange rates, base currency, normalisation, and the landed-cost and margin arithmetic |
enrichment | 4 | API key and model configuration, and the offline fallback path. Includes the one live check |
integrations | 7 | API keys, the pixel, event ingest, recommendations and the suggestion miner |
security | 5 | Sessions, the owner account, credential leaks in served assets, and route gating |
ops | 8 | Disk, database and WAL size, query latency, the health endpoint, static assets |
writes | 1 | The 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
/v1/diagnostics/runreadRuns 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.
| Field | Notes |
|---|---|
groups | Group names — an array, or a comma-separated string. Omit for all |
checks | Individual check ids, same two forms. Combines with groups as a union |
include_writes | owner Also run the write self-test |
include_live_ai | owner 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"] }'
const res = await fetch('https://simplypim.co.uk/v1/diagnostics/run', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ groups: ['core', 'search'] }),
});
const { summary, results } = await res.json();
if (summary.fail > 0) alert(results.filter((r) => r.status === 'fail'));
{
"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 field | Notes |
|---|---|
id, group, label | Which check this is |
status | pass / warn / fail / skip |
detail | One line an admin can read. Numbers over adjectives |
ms | Wall-clock time for this check, to one decimal |
remedy | Present on every warn and fail, and never on a pass |
data | Optional 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:
{} — 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 }
]
}
warns from that run, with their remediesWARN 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:
| Thing | Who | Why |
|---|---|---|
A read-only run, and GET /v1/diagnostics | Any authenticated caller | It changes nothing, and a deploy gate needs it |
include_writes | Owner | It writes to the catalogue, however carefully |
include_live_ai | Owner | It spends the install's money |
| The support bundle | Owner, unconditionally | It 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:
editor session asking for writes{ "error": "forbidden", "message": "Owner access is required for the write self-test" }
{ "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 }'
const res = await fetch('https://simplypim.co.uk/v1/diagnostics/run', {
method: 'POST',
headers: {
'X-API-Key': process.env.SIMPLYPIM_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ groups: ['writes'], include_writes: true }),
});
const [selfTest] = (await res.json()).results;
console.log(selfTest.status, selfTest.data.steps.length, 'steps');
{
"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:
{
"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.
| Route | Returns |
|---|---|
GET /v1/diagnostics/report.json | The bundle as JSON, Content-Disposition: attachment |
GET /v1/diagnostics/report.txt | The same bundle rendered as plain text, for pasting into an email |
| Query | Notes |
|---|---|
fresh | 1/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"
const res = await fetch('/v1/diagnostics/report.json?fresh=1', {
credentials: 'same-origin',
});
if (res.status === 403) return; // not the owner
const blob = await res.blob();
// the filename is in Content-Disposition
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-disposition: attachment; filename="simplypim-diagnostics-2026-07-30.json"
{
"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.
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
{
"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:
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):
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 → Link →
https://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:
| Transport | Use it when |
|---|---|
| stdio | Operators only — a desktop client running on the same machine as the server process itself. As a customer you connect over HTTP. |
Streamable HTTP — POST /mcp | A hosted agent, which cannot spawn a process inside your install. Connects with a URL and an X-API-Key. |
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.
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:
{
"error": "validation_error",
"message": "Request body failed validation",
"details": [
{ "path": "name", "message": "String must contain at least 1 character(s)" }
]
}
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request / validation_error | Malformed input; details[] points at the field |
| 401 | unauthorized | No credential on a protected route |
| 401 | invalid_api_key | Key not recognised (wrong, deleted or rotated away) |
| 401 | key_revoked | The key was revoked |
| 401 | invalid_credentials | Sign-in failed. Identical for a wrong password and an unknown email — deliberately, so the route cannot enumerate accounts |
| 403 | insufficient_scope | Valid key, wrong scope — the body lists required_scopes |
| 403 | origin_not_allowed | Origin/Referer is not on the key's allowlist |
| 403 | ip_not_allowed | Caller IP is not on the key's allowlist |
| 403 | forbidden | Signed 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 |
| 404 | not_found | Unknown SKU, category, attribute, attribute set, family, price line, location, status, condition, unit, suggestion, saved view or route |
| 404 | plan_not_found | Unknown plan_id on POST /v1/account/subscribe |
| 402 | plan_limit_reached | An active subscription's product ceiling, enforced inside the products module. The body carries limit and count. A child line counts as a product |
| 409 | plan_limit_reached | The same wall via the opt-in enforce_plan_limits gate. Same error string, so match on that rather than on the status |
| 409 | email_taken | Signup with an email that already has an account |
| 409 | key_quota_exceeded | You hold as many live keys as api_key_quota_per_user allows; details carries quota and count |
| 409 | conflict | Duplicate 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 |
| 429 | rate_limited | Per-key limit; retry after retry_after seconds |
| 500 | internal_error | Our fault. The response never leaks internals; the server logs carry a request id |
| 500 | redaction_failed | The 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
| Thing | Limit |
|---|---|
| Request body | 2 MB |
| Bulk import | 500 products per request |
| Product list page | limit max 200 (default 20) |
| CSV export | limit max 50,000 (the hard ceiling too); 100 columns |
| Search page | limit max 100 (default 20); q max 300 characters |
| Advanced find | q max 200 characters; 50 values per list filter; 200 category_ids |
| Completeness filter / sort | 20,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 write | 500 values; tags up to 500 entries |
| Attribute sets | 200 members per set; 200 categories per assignment; code 64 characters, label 120, description 2,000 |
| Prices / stock / media / relations per write | 50 / 100 / 50 / 200 rows |
| Units per write | 500 per request, all-or-nothing; serial 120 characters, notes 2,000 |
| Inheritance | One level, one master; 200 inherit tokens per call |
| Keywords | 50 manual, 15 auto, 15 suggestions per call |
| Saved views / prefs | 200 views per user; 32 KB per filters/sort/columns blob and per pref value; 500 prefs listed |
| Exchange rates | Considered stale after 24 hours; markup_pct −100 to 1000 |
| Insights window | days clamped to 1–730; 2,000 history rows and 500 competitor captures per response |
| Relation suggestions | limit max 200 (default 50); 90-day mining window; a session touching more than 40 distinct SKUs is discarded as a crawler |
| Diagnostics | 47 checks (read check_count, do not hard-code it); one write self-test at a time, its lock going stale after 5 minutes |
| Per key | 20 origins, 20 IP entries, your chosen requests/minute; api_key_quota_per_user live keys per person (default 20) |
| Sessions | pim_session valid 30 days; password 8 characters minimum |
- Dates are ISO (
YYYY-MM-DD); timestamps are UTCYYYY-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: theprice_gte/price_ltefilters,amount_minor, and everything insidemarginandnormalized. - Codes — attribute codes are
lower_snake_case; category, price-line, family, status and condition codes arelower-kebab-case. A code is immutable once anything stores it: change thelabelinstead. - Versioning — response shapes only ever gain keys within
/v1; anything breaking would arrive as/v2. - CORS is open (
*) withContent-TypeandX-API-Keyallowed, so browser calls work; protect keys with the origin allowlist rather than with CORS.