Public API
The public API serves a project's brands, products, merchants and prices to a comparison website. It is read-only apart from one batch endpoint for click reports. Every website of a project has its own token and sees the project through its own languages, terms and price settings. Register websites in Websites.
All routes live under /api/public/v1 on the platform's origin.
Authentication
Send the website token as a bearer token:
curl -H "Authorization: Bearer pwt_xxxxxxxx" \
"https://platform.example/api/public/v1/website"
- The token (
pwt_…) identifies the website. It is shown once when the website is created or its token rotated. The platform stores only a SHA-256 hash. - The token is a server-side secret. Call the API from the website's server (SSR, server routes, a Worker), never from the browser. The API sends no CORS headers.
- After a rotation the previous token is accepted for 24 hours. A deleted website's token stops working immediately.
- A missing or invalid token answers
401withdata.codeset tounauthorizedand aWWW-Authenticate: Bearerheader.
Responses carry Cache-Control: private, max-age=60, Vary: Authorization and X-Robots-Tag: noindex, so a shared cache never serves one website's data to another.
Conventions
| Topic | Rule |
|---|---|
| Prices | Integer cents with currency: { "amountCents": 7980, "currency": "NOK" }. All prices are in the project's base currency. An offer carries originalPrice when the merchant prices in another currency, otherwise null. |
| Dates | ISO 8601 strings. Price history days are YYYY-MM-DD (UTC). |
| Identifiers | Public identifiers are slugs. The only opaque id is the offer id inside outboundUrl. |
| Locale | Every GET route accepts ?locale=. The response is served in the website language that best matches it: the exact locale, then its language (nb-NO to nb), then a regional variant (nb to nb-NO), then the website's default language. An unsupported locale is not an error. |
| Pagination | List routes take page (default 1) and pageSize (default 24, at most 100) and return { items, total, page, pageSize }. |
| Errors | JSON with statusCode, statusMessage, message and data. data.code is unauthorized or not_found where set. |
What is served
Only live data is served:
- An offer is shown when it is active, has a price, and its merchant is not deprecated. With the website's Hide zero prices on (default), a price of 0 counts as no price.
- A product is listed when it has a live offer. Brands and merchants are listed when they have at least one such product.
- Deprecated brands, products and merchants are hidden. Their slug answers
404withdata.replacedBySlugwhen they were merged into another, so the site can redirect permanently. - Custom fields are served only when marked Public.
- Notes, scrape settings, raw results, internal ids and merchant URLs are never served.
Products carry name (without brand) and displayName in the project's product name format (default {brand.name} {product.name}). Use displayName in titles, headings and links.
SEO fields
Brand, merchant and product detail responses carry seo: { title, description, noindex }: the entity's own SEO texts, else the project's SEO template, else null. Titles do not include the website's title template. noindex entities should render robots: noindex and are left out of /sitemap.
Endpoints
GET /website
The calling website and catalogue totals. Parameters: locale.
Returns id, name, fqdn, the served locale, locales (default first), currency, pricesIncludeVat, terms (product, brand, merchant), translations (free texts), seo (titleTemplate, homeTitle, homeDescription, ogImage) and counts (brands, products, merchants, offers). Terms, texts and SEO come from Website content and fall back to the default language.
GET /brands and GET /brands/:slug
| Parameter | Values |
|---|---|
locale, page, pageSize, q | As above. q filters by name (contains). |
sort | name (default), productCount, minPrice |
order | asc (default), desc |
List items: slug, name, productCount, minPrice. The detail adds description, seo, attributes, merchantCount and offerCount.
GET /products and GET /products/:slug
| Parameter | Values |
|---|---|
locale, page, pageSize, q | As above. |
brand | Brand slug. |
merchant | Merchant slug: only products this merchant sells. |
sort | name (default), minPrice, offerCount |
order | asc (default), desc |
List items: slug, name, displayName, brand (slug, name), attributes, minPrice, offerCount. The detail adds description, seo, updatedAt and offers, cheapest first. Each offer has id, merchant (slug, name, websiteHost), price, originalPrice, inStock (null when the merchant does not report stock), lastCheckedAt, isCheapest, outboundUrl and attributes.
Use outboundUrl as the link target of the buy button, with rel="nofollow sponsored". See Click tracking.
curl -H "Authorization: Bearer $LISTINGS_TOKEN" \
"https://platform.example/api/public/v1/products?brand=xqs&sort=minPrice&pageSize=12&locale=nb-NO"
GET /products/:slug/similar
Products of the same brand first, then the closest in price.
| Parameter | Values |
|---|---|
locale | As above. |
limit | 1 to 24, default 8 |
Returns an array of product list items.
GET /products/:slug/price-history
| Parameter | Values |
|---|---|
days | 1 to 365, default 90 |
Returns { currency, days, points }. Each point is { date, minCents, offerCount }: the lowest price across the product's active offers on that day, oldest first. Prices carry forward between changes, and days without any price are left out.
GET /merchants and GET /merchants/:slug
| Parameter | Values |
|---|---|
locale, page, pageSize, q | As above. |
sort | avgPrice (default), name, productCount |
order | asc (default), desc |
List items: slug, name, websiteHost, productCount, avgPrice. The detail adds description, seo, attributes and minPrice.
GET /search
| Parameter | Values |
|---|---|
q | Required, 1 to 200 characters. |
types | Comma separated list of product, brand, merchant. Default: all. |
limit | 1 to 50, default 10 |
Returns { q, hits }. A hit has type, slug, name, displayName and, for products, brand. Search matches the default-language names.
GET /sitemap
Every indexable brand, merchant and product with its last change: { items: [{ type, slug, updatedAt }] }. Entities marked noindex are left out. @product-comparison/catalog-kit has buildSitemapXml to turn it into XML.
GET /go/:offerId
For self-hosted redirect clients. Query: s (the signature from the link). Returns { target, kind, trackable, ttl }. It never records a click. See Click tracking.
POST /clicks
For self-hosted redirect clients. Reports clicks in batches. See Click tracking.
Example flow
A product page needs the website settings once and the product on each request:
# Website settings: languages, terms, texts, SEO, currency
curl -H "Authorization: Bearer $LISTINGS_TOKEN" "$BASE/api/public/v1/website?locale=nb-NO"
# The product with its offers
curl -H "Authorization: Bearer $LISTINGS_TOKEN" "$BASE/api/public/v1/products/ettan-loos?locale=nb-NO"
A 404 with data.replacedBySlug means the product was merged: answer with a permanent redirect to /products/<replacedBySlug>.