# Dials Docs How the Dials watch catalogue is built: the philosophy, where the data comes from, and the public API that serves all of it. # Overview [Dials](https://dials.co) is a reference catalogue of wristwatches: every model described to the same specification, with live marketplace listings matched against it. This documentation explains the thinking behind the catalogue, where its data comes from, and how to read all of it programmatically. ## What is here * **[Philosophy](/philosophy)**: why the catalogue exists and the principles it is built on. * **[Where the data comes from](/data)**: how the catalogue and the listings are sourced, matched and kept clean, and the shape of [the catalogue](/data/catalogue) and [the listings](/data/listings). * **[The API](/api/quickstart)**: everything the catalogue knows, as JSON, with no key and no account, plus the [interactive reference](https://docs.dials.co/api/reference) generated from the OpenAPI document. :::tip[In a hurry?] The API needs no sign-up. `curl 'https://api.dials.co/v1/models?q=116610LN'` resolves a reference number to a model; the [quickstart](/api/quickstart) takes it from there. ::: # Philosophy Dials is built on a premise that is narrow and, as far as we can tell, unmet elsewhere: **describe every watch to the same specification**, so that two of them can be compared without first translating between two houses' idea of what a spec sheet is. ## The problem Look up the same watch on three sites and you get three different documents. One gives you the case diameter but not the lug-to-lug. One lists the movement by name and never says its power reserve. One buries the water resistance in a paragraph of marketing. None of them agree on what a "reference number" is, or where the seams in it fall. That is fine when you are reading about one watch. It falls apart the moment you want to answer a comparative question: which of these two is thinner, which movement is actually the same ébauche under two names, what does this reference cost today across the market. Every one of those questions requires the same fields, filled in the same way, for both watches. So that is what the catalogue is: the same fields, filled in the same way. ## The principles **One specification.** A model is the unit of description. One watch, one record: case material and dimensions, calibre and its properties, dial, bracelet, water resistance, production years. Two models are directly comparable field by field, with no translation step. Everything else on the site is downstream of this rule. **Listings keep their source.** The marketplace layer shows what watches are actually selling for, but the listings are not ours. Each one names the dealer or platform that published it and links back to them; anything about price, condition, availability, shipping or returns is theirs to answer. Dials takes no cut of anything and nothing here transacts. **Reference data over opinion.** Prices shown are what listings ask, which is not the same as what watches are worth. The catalogue records facts about models, not judgements about individual physical watches: no valuations, no authentication, no provenance. **Open by design.** A structured catalogue whose whole value is that its fields are consistent should not be locked behind a rendering of those fields. The catalogue is fully readable over a [public API](/api/quickstart) with no key and no account, every page of dials.co is also served as markdown to any client that asks, and this documentation follows the same rule. If you want to compare two watches programmatically, you should not have to scrape a page to do it. ## What Dials is not It is not a marketplace: nothing here buys or sells. It is not a valuation service. It is not an authentication service, and no data here can tell you whether a specific physical watch is genuine. And it is not a forum; there is no community layer and none is planned. ## Contact Corrections, additions and questions: [hello@dials.co](mailto\:hello@dials.co). Catalogue corrections are the most useful thing you can send: say which model, which field, and what it should be. # Where the data comes from Dials has two layers, and they are sourced differently on purpose. ## The catalogue: from the manufacturers The reference layer (brands, series, models, calibres, complications) starts from what the watch houses publish about their own watches: official catalogues and specification sheets. That material arrives in as many formats as there are houses, so the work is normalisation: every model is rewritten into the one specification the whole catalogue shares, and the seams in each house's reference-number grammar are mapped so a reference can be resolved reliably. Where a manufacturer's published data is incomplete or ambiguous, fields are filled from documented sources or left empty. An empty field is honest; a guessed one poisons every comparison that touches it. ## The listings: from the market The marketplace layer is collected from the dealers and platforms we track. Each listing keeps three things: * **A link back to its source.** The listing belongs to whoever published it; Dials always points at them. * **What the seller said, verbatim.** The brand, reference and title as the source stated them are stored unchanged, separately from anything Dials concludes about the watch. * **Its market state.** Listings are revisited regularly; when one disappears from its source it is marked delisted rather than deleted, so the record of what was asked, and when, survives. ## Matching listings to models A listing is linked to a catalogue model by **brand plus reference number**. Where the reference is unambiguous the match is automatic; where it is not, a person reviews it. An unmatched listing is never thrown away: it is kept and queued for reconciliation, because the listings that fail to match are frequently watches the catalogue was missing. The unmatched queue is one of the catalogue's best sources of growth. ## Images Studio photographs arrive from many sources against as many different backgrounds; lined up in a grid, the eye reads the backgrounds before it reads the watches. So every image is cut out, squared and re-hosted on the Dials CDN, and the site renders the processed version. Source images are never hotlinked; the same courtesy is asked of anyone [building on the API](/api/terms). ## Corrections The pipeline is built to be corrected. If a field is wrong, say which model, which field, and what it should be: [hello@dials.co](mailto\:hello@dials.co). :::note[Statistics] Market statistics (aggregate pricing and trends) are planned but not live. The methodology will be documented here when they ship, before anyone is asked to trust a number. ::: # The catalogue The reference layer is a strict hierarchy with two side branches: ``` Brand ──► Series ──► Model │ └─► Caliber ◄──► Complication ``` ## Brands, series, models A **brand** is a watch house: Rolex, A. Lange & Söhne, Seiko. A **series** is a named line inside it: Submariner, Lange 1, Prospex. A **model** is one watch: a specific reference with one specification. The model is the unit of description. Its record carries the fields the whole catalogue agrees on: reference number, case material and dimensions, the calibre inside and its properties, dial, bracelet or strap, water resistance, production years, pictures. Two structural details are worth knowing before you build against the data: * **A model is unique within its brand, not globally.** Reference numbers repeat across makers: Lange and Sinn both ship a `212.050`. Every model lookup is therefore scoped by brand, and the API addresses a model as `/models/{brandSlug}/{slug}`, never by reference alone. * **A model can carry alternate references.** Some watches are known under more than one reference (regional variants, "also known as" numbers). The primary reference stays exact, and the alternates are recorded beside it, so a search by either finds the watch. ## Calibres A **calibre** is a movement, catalogued per brand under `/calibers/{brandSlug}/{ref}`. Calibres can reference a parent: a base movement that other calibres derive from. That relationship is how the catalogue answers one of its favourite questions: which watches from different houses actually share an ébauche. A calibre's page lists every model across the catalogue that runs it. ## Complications Complications (date, chronograph, GMT, moonphase and so on) are catalogued once and linked to the calibres that implement them, so they behave as facets: you can walk from a complication to every movement that carries it, and from there to every watch. ## Slugs and addresses Every entity has a stable, human-readable slug, and the catalogue's URL grammar is consistent between the site and the API: | Entity | On dials.co | On the API | |---|---|---| | Brand | `dials.co/watches/{brand}` | `/v1/brands/{slug}` | | Series | `dials.co/watches/{brand}/{series}` | `/v1/brands/{brandSlug}/series/{seriesSlug}` | | Model | `dials.co/watches/{brand}/{series}/{model}` | `/v1/models/{brandSlug}/{slug}` | | Calibre | `dials.co/calibers/{brand}/{ref}` | `/v1/calibers/{brandSlug}/{ref}` | Model slugs are chosen to stay unique within a brand even where reference numbers alone would collide; treat the slug, not the reference, as the address. # Listings and matching The marketplace layer answers one question the reference layer cannot: what is this watch actually selling for, right now. It is populated from the dealers and platforms Dials tracks, and every listing stays attributed and linked to its source. ## What a listing records * **The source**: which provider published it, with a link back. Price, condition, availability, shipping and returns are always the seller's to answer. * **The claim**: the brand, reference and title exactly as the seller stated them. These are kept verbatim and separate from the match, so what the listing says it is and what Dials concluded it is can always be compared. * **The ask**: the listed price in its original currency. Prices are whole currency units, and each listing also carries a USD conversion used for sorting and filtering across currencies. * **Pictures**: re-hosted and normalised like every image on the site, never hotlinked from the source. ## Matching A listing is linked to a catalogue model by **brand plus reference number**. * Where the reference is exact and unambiguous, the match is automatic. * Where it is ambiguous, a person decides. * Where nothing matches, the listing is kept, unmatched, and queued for review. Unmatched listings are frequently watches the catalogue was missing, so the queue feeds the catalogue rather than a bin. A matched listing appears on its model's page and in [`GET /v1/listings?modelId=…`](/api/quickstart); an unmatched one stays out of the catalogue views until a person has resolved it. ## Lifecycle Two states live on every listing, deliberately independent of each other: * **The seller's stock state**: what the source says about availability (in stock, on request, sold). * **The marketplace lifecycle**: what Dials has verified. Listings are revisited regularly; one that disappears from its source is marked **delisted** (with a timestamp) rather than deleted, and one that comes back is reactivated. The history of what was asked, and when, survives the listing itself. ## Currency conversion Cross-currency prices use the European Central Bank's daily reference rates, published unmodified at [`GET /v1/rates`](/api/quickstart). The site and the API convert with the same table, so a price you compute from the API agrees with the one rendered on dials.co. # Quickstart Everything the catalogue knows is available as JSON. The public endpoints need **no key, no account and no `Authorization` header**; the catalogue is meant to be read. | Resource | Where | |---|---| | Base URL | `https://api.dials.co/v1` | | OpenAPI 3.0 document | [api.dials.co/openapi.json](https://api.dials.co/openapi.json) | | Interactive reference | [docs.dials.co/api/reference](https://docs.dials.co/api/reference) | | Agent index | [dials.co/llms.txt](https://dials.co/llms.txt) | ## First requests Resolve a reference number to a model: ```bash curl -s 'https://api.dials.co/v1/models?q=116610LN&pageSize=1' ``` Read that model's full specification: ```bash curl -s 'https://api.dials.co/v1/models/rolex/submariner-date-116610ln' ``` See what it is currently selling for, converted to euros: ```bash curl -s 'https://api.dials.co/v1/listings?modelId=¤cy=EUR' ``` Walk a house's catalogue: ```bash curl -s 'https://api.dials.co/v1/brands/rolex' curl -s 'https://api.dials.co/v1/brands/rolex/series' curl -s 'https://api.dials.co/v1/models?brandSlug=rolex&page=1&pageSize=50' ``` ## When to reach for this API It answers **structured questions about a specific watch**, which is a narrower job than it sounds and the one thing it does better than a search engine: * **Resolve a reference to a model.** Reference numbers are a compressed parts list, not a name, and every house has its own grammar. `GET /models?q=` does the lookup and gives you back fields, not prose. * **Compare two watches on the same axes.** Every model is described to one specification. Two `GET /models/{brandSlug}/{slug}` responses are directly comparable field by field (case dimensions, calibre, power reserve, water resistance) with no translation step. * **Price a watch against the live market.** `GET /listings?modelId=…` returns current offers from the tracked marketplaces. `?currency=` converts, using the same ECB table `GET /rates` publishes, so your numbers and ours agree. * **Identify a movement.** `GET /calibers/{brandSlug}/{ref}` gives the calibre's properties and every model across the catalogue that runs it. * **Enumerate a catalogue.** Brand, then series, then models, paginated, for building a picker or filling a table. **Do not** reach for it to value a watch, to authenticate one, to look up anything about a *specific physical* watch (serial numbers, service history, provenance), or to transact: nothing here buys or sells. ## Endpoints | Method | Path | What it returns | |---|---|---| | `GET` | `/brands` | A page of watch houses, with counts and a lead picture | | `GET` | `/brands/highlighted` | The hand-picked row the home page leads with | | `GET` | `/brands/{slug}` | One brand | | `GET` | `/brands/{brandSlug}/series` | The series inside a brand | | `GET` | `/brands/{brandSlug}/series/{seriesSlug}` | One series | | `GET` | `/models` | A page of models; filter by brand, series, caliber or free text | | `GET` | `/models/{brandSlug}/{slug}` | One model, full specification and pictures | | `GET` | `/calibers/{brandSlug}/{ref}` | One movement, and the models that run it | | `GET` | `/listings` | Live marketplace listings | | `GET` | `/listings/{id}` | One listing | | `GET` | `/rates` | USD to display-currency conversion table | The full request and response shapes for each are in the [interactive reference](https://docs.dials.co/api/reference), generated from the same OpenAPI document the API serves. :::note The `/admin`, `/picture-review`, `/reconciliation` and `/duplicate-models` groups are the back office. They appear in the OpenAPI document for completeness and answer `401` to anyone without an admin session. ::: # Versioning and deprecation The current version is **v1**, at `https://api.dials.co/v1`. Build against the prefixed URL. The same paths also answer without the prefix (`https://api.dials.co/models`) as a permanent alias for whatever the current version happens to be. That is convenient for a quick `curl` and wrong to pin an integration to, because it moves when the current version moves. ## Inside a version * Changes are **additive only**: new endpoints, new optional parameters, new fields on a response. * A field is never removed, renamed or retyped, and a code in `error` is never repurposed. * A breaking change ships as a **new prefix** (`/v2`), served alongside the old one. ## When a version winds down Every response the deprecated version serves carries: * `Deprecation: true` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html)) * `Sunset: ` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html)), the date after which it stops answering, never less than six months out * a `Link` header with `rel="successor-version"` pointing at the replacement `X-API-Version` names the serving version on every response, deprecated or not, so a client can assert it is talking to what it thinks it is. # Rate limits Public callers get **600 requests per 60 seconds**. Every response carries the budget so you can pace yourself rather than discover the ceiling: ``` RateLimit-Limit: 600 RateLimit-Remaining: 573 RateLimit-Reset: 41 RateLimit-Policy: 600;w=60;policy="public" ``` `RateLimit-Reset` is seconds until the window rolls. A refused request answers **429** with `Retry-After` set to the same number of seconds. The limit is enforced per IP address and is deliberately generous; if you need more for something interesting, write to [hello@dials.co](mailto\:hello@dials.co) rather than sharding across addresses. # Errors Every failure returns JSON in one shape: a rejected parameter, an unknown path, a fault on our side alike. There are no HTML error pages anywhere on the API. ```json { "error": "not_found", "message": "Brand not found.", "hint": "Check the slug against GET /brands.", "docs": "https://api.dials.co/docs" } ``` `error` is the stable, machine-readable part and is safe to branch on: | Code | Meaning | |---|---| | `invalid_request` | A parameter or the path shape was rejected | | `unauthorized` | The endpoint needs a session you do not have | | `forbidden` | The session exists but may not do this | | `not_found` | The resource does not exist | | `rate_limited` | Over budget; see [rate limits](/api/rate-limits) | | `internal_error` | Our fault; safe to retry with backoff | `message` is prose and may be reworded. `hint` says what to change about the request. # Agent access Dials treats machine readers as first-class. There are three routes in, and none of them involve parsing rendered HTML. ## The OpenAPI document as a tool set The OpenAPI document at [api.dials.co/openapi.json](https://api.dials.co/openapi.json) is written to be turned into tools without a human naming each one: * Every operation has a **unique `operationId`** (`getModel`, `listListings`, `getCaliber`) usable directly as a function name. * Every operation has a **description** saying what it returns and when to reach for it. * Every parameter and every response is **typed**, including the error bodies. * `servers`, `contact`, `license` and `externalDocs` are all populated, so a generator has somewhere to point. Point your tool generator at it and it should need no hand editing. ## Markdown instead of HTML on dials.co Every public page on `dials.co` is also served as markdown, following the [acceptmarkdown.com](https://acceptmarkdown.com) convention. Ask for it: ```bash curl -s -H 'Accept: text/markdown' https://dials.co/about ``` You get `Content-Type: text/markdown; charset=utf-8` and `Vary: Accept`. A client that asks for a media type the site cannot produce gets a `406` naming what is available, rather than an HTML page it then has to strip. A catalogue page that renders as roughly 100 KB of HTML is under 1 KB as markdown; this is the intended route for anything reading the site rather than the API. The index of everything readable this way is at [dials.co/llms.txt](https://dials.co/llms.txt). ## This site is readable the same way The documentation you are reading follows the same rule: * [docs.dials.co/llms.txt](https://docs.dials.co/llms.txt) is a concise index of every page here. * [docs.dials.co/llms-full.txt](https://docs.dials.co/llms-full.txt) is the whole site's content in one file. * Every page is also available as plain markdown under `/assets/md/`, mirroring the page path: for example `/assets/md/api/quickstart.md`. The "Copy page for AI" control on each page hands out the same file. # Terms of use The API is free to read and free to build on, within the [rate limits](/api/rate-limits). Two conditions: * **Do not present the catalogue as your own.** Attribute it, with a link to the model page you took the data from. * **Do not hotlink the images.** They are served from our CDN and paid for by us; copy what you need instead. The listings are not ours. Each one belongs to the dealer or platform that published it and links back to them; anything about price, condition, availability, shipping or returns is theirs to answer. Questions: [hello@dials.co](mailto\:hello@dials.co). # Dials API Version: `1.0.0` A read-only reference for wristwatches: the catalogue (brands, series, models, movements) and the marketplace listings matched against it. ## When to use this API Reach for it when you need **structured facts about a specific watch** rather than prose about watches in general: - Resolve a reference number to a model (`GET /models?q=116610LN`) and read back the case size, movement, dial and bracelet as fields rather than sentences. - Compare two watches on the same axes. Every model in the catalogue is described to one specification, which is the point of it; two `GET /models/{brandSlug}/{slug}` calls are directly comparable field by field. - Find what a watch is currently selling for: `GET /listings?modelId=...` returns live listings from the tracked marketplaces, with `?currency=EUR` doing the conversion for you off the same ECB table `GET /rates` publishes. - Identify a movement (`GET /calibers/{brandSlug}/{ref}`) and see which models across the catalogue run it. - Enumerate a house's catalogue: `GET /brands/{slug}`, then `GET /brands/{brandSlug}/series`, then `GET /models?brandSlug=...`. It is **not** the right tool for valuations or authentication advice, for anything about a specific physical watch (serial numbers, service records, provenance), or for placing an order: nothing here transacts. ## Access The catalogue, listings and rates endpoints are public: no key, no account, no `Authorization` header. The `/admin`, `/picture-review`, `/reconciliation` and `/duplicate-models` groups are the back office and require an admin session cookie; they are documented for completeness and will answer 401 to an anonymous caller. ## Versioning and deprecation The current version is **v1**, served at `https://api.dials.co/v1`. Build against the prefixed URL. The same paths answer without the prefix as a permanent alias for whatever the current version is: handy for a quick curl, wrong to pin an integration to. Inside a version, changes are additive only: new endpoints, new optional parameters, new response fields. A field is never removed, renamed or retyped, and a code in `error` is never repurposed. A breaking change ships as a new prefix (`/v2`) served alongside the old one. When a version begins winding down, every response from it carries `Deprecation: true` (RFC 9745) and `Sunset: ` (RFC 8594); the date it stops answering, never less than six months out, plus a `Link` header with `rel="successor-version"`. `X-API-Version` names the serving version on every response. ## Rate limits Public callers get 600 requests per 60 seconds. Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the window rolls) and `RateLimit-Policy`, so a client can pace itself instead of discovering the ceiling. A refused request answers **429** with `Retry-After`. ## Errors Every failure (a bad parameter, an unknown path, a fault on our side) returns JSON with the same fields: `error` (a stable machine-readable code), `message` (prose), `hint` (what to change) and `docs` (where to read more). Codes: `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `rate_limited`, `internal_error`. ## Servers - `https://api.dials.co/v1`: Version 1; pin to this. - `https://api.dials.co`: Unversioned alias for the current version. Convenient, not stable. ## Endpoints ### Brands Watch houses, and the series inside them. - [`GET /brands`](/api/reference/brands#listbrands): List brands - [`GET /brands/highlighted`](/api/reference/brands#listhighlightedbrands): List the highlighted brands - [`GET /brands/{slug}`](/api/reference/brands#getbrand): Get a brand by slug - [`GET /brands/{brandSlug}/series`](/api/reference/brands#listbrandseries): List a brand's series - [`GET /brands/{brandSlug}/series/{seriesSlug}`](/api/reference/brands#getseries): Get a series by brand + series slug ### Models The unit of the catalogue: one watch, described to one specification. - [`GET /models`](/api/reference/models#listmodels): List watch models - [`GET /models/{brandSlug}/{slug}`](/api/reference/models#getmodel): Get a model (full spec) by brand + slug ### Calibers Movements, and the models that run them. - [`GET /calibers/{brandSlug}/{ref}`](/api/reference/calibers#getcaliber): Get a caliber (movement) by brand + reference ### Listings Live marketplace offers, matched to catalogue models where possible. - [`GET /listings`](/api/reference/listings#listlistings): List listings - [`GET /listings/stats`](/api/reference/listings#getlistingpricestats): Asking-price summary for one model - [`GET /listings/{id}`](/api/reference/listings#getlisting): Get a single listing ### Rates The USD conversion table listing prices are rendered with. - [`GET /rates`](/api/reference/rates#getexchangerates): USD → display-currency conversion rates ### Sitemap Bulk, cheap enumeration of every catalogue entity that has a page: identity and last-modified date only. - [`GET /sitemap/models`](/api/reference/sitemap#listsitemapmodels): Every model's URL identity and last-modified date - [`GET /sitemap/catalog`](/api/reference/sitemap#listsitemapcatalog): Every brand and series, as URL identities - [`GET /sitemap/calibers`](/api/reference/sitemap#listsitemapcalibers): Every movement, as a URL identity ### Admin Back office. Requires an admin session. - [`GET /admin/stats`](/api/reference/admin#getadminstats): Catalog + marketplace counts (admin only) - [`GET /admin/unmatched-listings`](/api/reference/admin#listunmatchedlistings): Listings not yet linked to a catalog model (admin only) ### Picture review Back office: accepting candidate photographs. Requires an admin session. - [`GET /picture-review`](/api/reference/picture-review#listpicturereviewqueue): Models with picture candidates waiting on a decision - [`POST /picture-review/candidates/{id}/select`](/api/reference/picture-review#selectpicturecandidate): Use this candidate as the model's picture - [`POST /picture-review/pictures/{id}/keep`](/api/reference/picture-review#keepmodelpicture): Keep the current picture and stop offering the model for review - [`GET /picture-review/models/{id}/front-picture`](/api/reference/picture-review#getmodelfrontpicture): The picture the site leads with for a model - [`PUT /picture-review/models/{id}/front-picture`](/api/reference/picture-review#setmodelfrontpicture): Replace a model's front picture and/or set its protected flag ### Reconciliation Back office: linking unmatched listings to models. Requires an admin session. - [`GET /reconciliation`](/api/reference/reconciliation#listreconciliationgroups): Unmatched listings, grouped by the identity the provider claims - [`POST /reconciliation/models`](/api/reference/reconciliation#createmodelfromlistings): Create a catalog model from unmatched listings - [`POST /reconciliation/link`](/api/reference/reconciliation#linklistingstomodel): Link unmatched listings to an existing model - [`POST /reconciliation/models/update`](/api/reference/reconciliation#updatemodelfromlistings): Update an existing model from unmatched listings, then link them ### Duplicate models Back office: merging models that describe one watch. Requires an admin session. - [`GET /duplicate-models`](/api/reference/duplicate-models#listduplicatemodelgroups): Catalog models that are probably the same watch twice - [`POST /duplicate-models/merge`](/api/reference/duplicate-models#mergeduplicatemodels): Merge duplicate models into one survivor - [`POST /duplicate-models/dismiss`](/api/reference/duplicate-models#dismissduplicatemodelgroup): Mark a group as NOT duplicates # Admin Back office. Requires an admin session. ## Catalog + marketplace counts (admin only) `GET /admin/stats` Row counts across the catalogue and the marketplace, for the back office dashboard. Requires an admin session. ### Responses #### `200`: Counts. Body (`application/json`): - `brands` `integer` _(required)_ - `series` `integer` _(required)_ - `models` `integer` _(required)_ - `calibers` `integer` _(required)_ - `providers` `integer` _(required)_ - `listings` `integer` _(required)_ - `unmatchedListings` `integer` _(required)_ #### `401`: Not signed in. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `403`: Not an admin. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/admin/stats ``` ```ts fetch('https://api.dials.co/v1/admin/stats') ``` ## Listings not yet linked to a catalog model (admin only) `GET /admin/unmatched-listings` The reconciliation backlog: listings whose brand and reference did not match any catalogue model. Requires an admin session. ### Query parameters - `page` `integer` - `pageSize` `integer` ### Responses #### `200`: Reconciliation queue. Body (`application/json`): - `data` `object[]` _(required)_ - `id` `string` _(required)_ - `modelId` `string | null` _(required)_ - `price` `integer | null` _(required)_ - `currency` `string | null` _(required)_ - `priceUSD` `integer | null` _(required)_ - `convertedPrice` `integer | null`: priceUSD converted into the requested display currency - `convertedCurrency` `string`: Currency convertedPrice is denominated in - `url` `string ` _(required)_ - `providerCode` `string | null` _(required)_ - `year` `integer | null` _(required)_ - `condition` `string | null` _(required)_ - `availability` `string | null` _(required)_ - `status` `string` _(required)_ - `title` `string | null` _(required)_ - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `countryCode` `string | null` _(required)_ - `country` `string | null` _(required)_ - `city` `string | null` _(required)_ - `state` `string | null` _(required)_ - `boxAvailable` `boolean | null` _(required)_ - `papersAvailable` `boolean | null` _(required)_ - `lastSeenAt` `string ` _(required)_ - `delistedAt` `string | null` _(required)_ - `reservedAt` `string | null` _(required)_ - `createdAt` `string ` _(required)_ - `updatedAt` `string ` _(required)_ - `provider` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `pictures` `object[]` _(required)_ - `id` `string` _(required)_ - `url` `string` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ #### `401`: Not signed in. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `403`: Not an admin. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl 'https://api.dials.co/v1/admin/unmatched-listings?page=1&pageSize=24' ``` ```ts fetch('https://api.dials.co/v1/admin/unmatched-listings?page=1&pageSize=24') ``` # Brands Watch houses, and the series inside them. ## List brands `GET /brands` A page of watch houses, each with its series and model counts and one representative picture. Filter with `q`, order by name or catalogue depth with `sort`. ### Query parameters - `page` `integer` - `pageSize` `integer` - `q` `string` - `sort` `string` ### Responses #### `200`: A page of brands. Body (`application/json`): - `data` `object & object[]` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/brands?page=1&pageSize=24&q=string&sort=name_asc' ``` ```ts fetch('https://api.dials.co/v1/brands?page=1&pageSize=24&q=string&sort=name_asc') ``` ## List the highlighted brands `GET /brands/highlighted` The houses the home page leads with, in editorial order. A fixed, hand-picked row rather than a ranking, so it does not reshuffle when a spider runs. ### Responses #### `200`: The highlighted brands, in editorial order. Body (`application/json`): - `data` `object & object[]` _(required)_ ### Example request ```bash curl https://api.dials.co/v1/brands/highlighted ``` ```ts fetch('https://api.dials.co/v1/brands/highlighted') ``` ## Get a brand by slug `GET /brands/{slug}` One brand by its URL slug (`rolex`, `patek-philippe`). Slugs are stable and are what every other brand-scoped path takes. Includes `stats`, the shape of the brand's own catalogue: year and diameter ranges, case materials, movement count and how many of its watches are currently listed. ### Path parameters - `slug` `string` _(required)_ ### Responses #### `200`: The brand. #### `404`: Not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/brands/rolex ``` ```ts fetch('https://api.dials.co/v1/brands/rolex') ``` ## List a brand's series `GET /brands/{brandSlug}/series` The series (collections) inside one brand (Submariner, Nautilus), each with its model count and a lead model to illustrate it. ### Path parameters - `brandSlug` `string` _(required)_ ### Query parameters - `page` `integer` - `pageSize` `integer` - `q` `string` ### Responses #### `200`: A page of series. Body (`application/json`): - `data` `object & object[]` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ #### `404`: Brand not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl 'https://api.dials.co/v1/brands/rolex/series?page=1&pageSize=24&q=string' ``` ```ts fetch('https://api.dials.co/v1/brands/rolex/series?page=1&pageSize=24&q=string') ``` ## Get a series by brand + series slug `GET /brands/{brandSlug}/series/{seriesSlug}` One series, addressed by its brand and its own slug. Includes the parent brand, a lead model, and `stats`: the year and diameter ranges, case materials, movement count and current listing count derived from the series' own references. ### Path parameters - `brandSlug` `string` _(required)_ - `seriesSlug` `string` _(required)_ ### Responses #### `200`: The series. #### `404`: Not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/brands/rolex/series/submariner ``` ```ts fetch('https://api.dials.co/v1/brands/rolex/series/submariner') ``` # Calibers Movements, and the models that run them. ## Get a caliber (movement) by brand + reference `GET /calibers/{brandSlug}/{ref}` One movement by the brand that makes it and its reference (`rolex`/`3235`), with its specification and the models it powers. ### Path parameters - `brandSlug` `string` _(required)_ - `ref` `string` _(required)_ ### Responses #### `200`: The caliber. #### `404`: Not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/calibers/rolex/3235 ``` ```ts fetch('https://api.dials.co/v1/calibers/rolex/3235') ``` # Duplicate models Back office: merging models that describe one watch. Requires an admin session. ## Catalog models that are probably the same watch twice `GET /duplicate-models` Groups of catalogue models that the duplicate finder believes describe one watch, ordered by how strong the signal is. ### Query parameters - `page` `integer` - `pageSize` `integer` - `q` `string`: Search brand or reference - `kind` `string` ### Responses #### `200`: Candidate groups. Body (`application/json`): - `data` `object[]` _(required)_ - `kind` `string` _(required)_: The strongest signal that formed the group. - `models` `object[]` _(required)_: Oldest first. - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `alternateReferences` `string[]` _(required)_ - `slug` `string` _(required)_ - `brandName` `string` _(required)_ - `brandSlug` `string` _(required)_ - `series` `object` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `listingCount` `integer` _(required)_ - `thumbnailUrl` `string | null` _(required)_ - `createdAt` `string ` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_: Groups matching the filters. - `totalPages` `integer` _(required)_ - `summary` `object` _(required)_ - `sameReference` `integer` _(required)_ - `alternateOverlap` `integer` _(required)_ - `baseVariant` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/duplicate-models?page=1&pageSize=24&q=string&kind=all' ``` ```ts fetch('https://api.dials.co/v1/duplicate-models?page=1&pageSize=24&q=string&kind=all') ``` ## Merge duplicate models into one survivor `POST /duplicate-models/merge` Relinks the duplicates' listings to the keeper, folds their references into the keeper's alternates (so future crawls match either spelling), moves over pictures the keeper does not already have, backfills the keeper's empty fields, then deletes the duplicates. ### Request body (required) (`application/json`) - `keeperId` `string` _(required)_: The model that survives. - `duplicateIds` `string[]` _(required)_ ### Responses #### `200`: Merged. Body (`application/json`): - `keeper` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `mergedModels` `integer` _(required)_ - `movedListings` `integer` _(required)_ - `movedPictures` `integer` _(required)_ - `addedReferences` `integer` _(required)_ #### `400`: Invalid combination. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `404`: Model not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/duplicate-models/merge \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "keeperId": "string", "duplicateIds": [ "string" ] }' ``` ```ts fetch('https://api.dials.co/v1/duplicate-models/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keeperId: 'string', duplicateIds: ['string'] }) }) ``` ## Mark a group as NOT duplicates `POST /duplicate-models/dismiss` Stores every pair of the group so the finder never offers it again. ### Request body (required) (`application/json`) - `modelIds` `string[]` _(required)_ ### Responses #### `200`: Dismissed. Body (`application/json`): - `dismissedPairs` `integer` _(required)_ #### `404`: Model not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/duplicate-models/dismiss \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "modelIds": [ "string" ] }' ``` ```ts fetch('https://api.dials.co/v1/duplicate-models/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelIds: ['string'] }) }) ``` # Listings Live marketplace offers, matched to catalogue models where possible. ## List listings `GET /listings` A page of marketplace listings, watches currently offered for sale by the providers this catalogue tracks. Scope to one model with `modelId`, convert prices with `currency`. ### Query parameters - `page` `integer` - `pageSize` `integer` - `model` `string`: Model slug - `brand` `string`: Brand slug - `provider` `string`: Provider slug - `condition` `string | null` - `availability` `string | null` - `status` `string & unknown`: Lifecycle: ACTIVE, RESERVED, or DELISTED - `minPriceUSD` `integer | null` - `maxPriceUSD` `integer | null` - `unmatched` `boolean | null`: Only listings with no linked model - `includeDelisted` `boolean | null`: Include listings the delist sweep marked gone (hidden by default) - `sort` `string` - `currency` `string & unknown`: Also return priceUSD converted into this currency (USD is a no-op) ### Responses #### `200`: A page of listings. Body (`application/json`): - `data` `object[]` _(required)_ - `id` `string` _(required)_ - `modelId` `string | null` _(required)_ - `price` `integer | null` _(required)_ - `currency` `string | null` _(required)_ - `priceUSD` `integer | null` _(required)_ - `convertedPrice` `integer | null`: priceUSD converted into the requested display currency - `convertedCurrency` `string`: Currency convertedPrice is denominated in - `url` `string ` _(required)_ - `providerCode` `string | null` _(required)_ - `year` `integer | null` _(required)_ - `condition` `string | null` _(required)_ - `availability` `string | null` _(required)_ - `status` `string` _(required)_ - `title` `string | null` _(required)_ - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `countryCode` `string | null` _(required)_ - `country` `string | null` _(required)_ - `city` `string | null` _(required)_ - `state` `string | null` _(required)_ - `boxAvailable` `boolean | null` _(required)_ - `papersAvailable` `boolean | null` _(required)_ - `lastSeenAt` `string ` _(required)_ - `delistedAt` `string | null` _(required)_ - `reservedAt` `string | null` _(required)_ - `createdAt` `string ` _(required)_ - `updatedAt` `string ` _(required)_ - `provider` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `pictures` `object[]` _(required)_ - `id` `string` _(required)_ - `url` `string` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/listings?page=1&pageSize=24&model=string&brand=string&provider=string&condition=NEW&availability=IN_STOCK&status=ACTIVE&minPriceUSD=0&maxPriceUSD=0&unmatched=true&includeDelisted=true&sort=newest¤cy=USD' ``` ```ts fetch('https://api.dials.co/v1/listings?page=1&pageSize=24&model=string&brand=string&provider=string&condition=NEW&availability=IN_STOCK&status=ACTIVE&minPriceUSD=0&maxPriceUSD=0&unmatched=true&includeDelisted=true&sort=newest¤cy=USD') ``` ## Asking-price summary for one model `GET /listings/stats` How many watches of one model are currently offered, and the lowest, median and highest price being ASKED for them. Asking prices as advertised by the tracked providers: not transaction prices, and not a valuation. Convert with `currency`. ### Query parameters - `model` `string` _(required)_: Model slug - `brand` `string` _(required)_: Brand slug - `currency` `string & unknown`: Return the figures converted into this currency (USD is a no-op) ### Responses #### `200`: The asking-price summary. Body (`application/json`): - `count` `integer` _(required)_: Active listings for the model - `pricedCount` `integer` _(required)_: Of those, the ones carrying a price - `currency` `string & unknown` _(required)_ - `low` `integer | null` _(required)_: Lowest asking price - `median` `integer | null` _(required)_: Median asking price - `high` `integer | null` _(required)_: Highest asking price ### Example request ```bash curl 'https://api.dials.co/v1/listings/stats?model=string&brand=string¤cy=USD' ``` ```ts fetch('https://api.dials.co/v1/listings/stats?model=string&brand=string¤cy=USD') ``` ## Get a single listing `GET /listings/{id}` One listing by id, with its price, condition, provider and the catalogue model it was matched to (when it was matched to one). ### Path parameters - `id` `string` _(required)_ ### Responses #### `200`: The listing. Body (`application/json`): - `id` `string` _(required)_ - `modelId` `string | null` _(required)_ - `price` `integer | null` _(required)_ - `currency` `string | null` _(required)_ - `priceUSD` `integer | null` _(required)_ - `convertedPrice` `integer | null`: priceUSD converted into the requested display currency - `convertedCurrency` `string`: Currency convertedPrice is denominated in - `url` `string ` _(required)_ - `providerCode` `string | null` _(required)_ - `year` `integer | null` _(required)_ - `condition` `string | null` _(required)_ - `availability` `string | null` _(required)_ - `status` `string` _(required)_ - `title` `string | null` _(required)_ - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `countryCode` `string | null` _(required)_ - `country` `string | null` _(required)_ - `city` `string | null` _(required)_ - `state` `string | null` _(required)_ - `boxAvailable` `boolean | null` _(required)_ - `papersAvailable` `boolean | null` _(required)_ - `lastSeenAt` `string ` _(required)_ - `delistedAt` `string | null` _(required)_ - `reservedAt` `string | null` _(required)_ - `createdAt` `string ` _(required)_ - `updatedAt` `string ` _(required)_ - `provider` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `pictures` `object[]` _(required)_ - `id` `string` _(required)_ - `url` `string` _(required)_ #### `404`: Not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/listings/clx123 ``` ```ts fetch('https://api.dials.co/v1/listings/clx123') ``` # Models The unit of the catalogue: one watch, described to one specification. ## List watch models `GET /models` A page of models, the catalogue's unit of description. Every model belongs to a brand and a series and carries one specification: case, calibre, dial, bracelet. Filter by brand, series, caliber or free text; sort by name, newest or reference. ### Query parameters - `page` `integer` - `pageSize` `integer` - `q` `string` - `id` `string`: Exact model id - `brand` `string`: Brand slug - `series` `string`: Series slug - `caliber` `string`: Caliber slug - `sort` `string` ### Responses #### `200`: A page of models. Body (`application/json`): - `data` `object | null[]` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `thumbnailUrl` `string | null` _(required)_ - `thumbnailIsCropped` `boolean` _(required)_ - `brand` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `series` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `listingCount` `integer` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/models?page=1&pageSize=24&q=string&id=string&brand=string&series=string&caliber=string&sort=newest' ``` ```ts fetch('https://api.dials.co/v1/models?page=1&pageSize=24&q=string&id=string&brand=string&series=string&caliber=string&sort=newest') ``` ## Get a model (full spec) by brand + slug `GET /models/{brandSlug}/{slug}` One model in full: the complete specification, its pictures, its movement, and the series and brand it sits under. ### Path parameters - `brandSlug` `string` _(required)_ - `slug` `string` _(required)_ ### Responses #### `200`: The model. Body (`application/json`): - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `description` `string | null` _(required)_ - `updatedAt` `string` _(required)_ - `releaseYear` `integer | null` _(required)_ - `discontinuationYear` `integer | null` _(required)_ - `limitedAmount` `integer | null` _(required)_ - `diameter` `number | null` _(required)_ - `thickness` `number | null` _(required)_ - `lugWidth` `number | null` _(required)_ - `waterResistance` `number | null` _(required)_ - `caseMaterials` `string[]` _(required)_ - `bezel` `string | null` _(required)_ - `bezelMaterials` `string[]` _(required)_ - `bezelColors` `string[]` _(required)_ - `coating` `string | null` _(required)_ - `shape` `string | null` _(required)_ - `glass` `string | null` _(required)_ - `back` `string | null` _(required)_ - `dialNickname` `string | null` _(required)_ - `dialFinish` `string | null` _(required)_ - `dialColors` `string[]` _(required)_ - `dialMaterials` `string[]` _(required)_ - `indexes` `string | null` _(required)_ - `hands` `string | null` _(required)_ - `bandNickname` `string | null` _(required)_ - `bandColors` `string[]` _(required)_ - `bandMaterials` `string[]` _(required)_ - `bandClasp` `string | null` _(required)_ - `bandWidth` `number | null` _(required)_ - `brand` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `series` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `caliber` `object & unknown` _(required)_ - `pictures` `object[]` _(required)_ - `id` `string` _(required)_ - `url` `string` _(required)_ - `type` `string` _(required)_ - `croppedUrl` `string | null` _(required)_ #### `404`: Not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/models/rolex/126610ln ``` ```ts fetch('https://api.dials.co/v1/models/rolex/126610ln') ``` # Picture review Back office: accepting candidate photographs. Requires an admin session. ## Models with picture candidates waiting on a decision `GET /picture-review` The picture review queue: models for which the image finder proposed candidate photographs that nobody has accepted or rejected yet. ### Query parameters - `page` `integer` - `pageSize` `integer` - `status` `string` ### Responses #### `200`: Review queue. Body (`application/json`): - `data` `object[]` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `brand` `string` _(required)_ - `query` `string | null` _(required)_ - `pictures` `object[]` _(required)_ - `id` `string` _(required)_ - `url` `string` _(required)_ - `type` `string` _(required)_ - `croppedUrl` `string | null` _(required)_ - `protected` `boolean` _(required)_ - `candidates` `object[]` _(required)_ - `id` `string` _(required)_ - `url` `string` _(required)_ - `sourceUrl` `string | null` _(required)_ - `width` `integer | null` _(required)_ - `height` `integer | null` _(required)_ - `rank` `integer` _(required)_ - `selectedAt` `string | null` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/picture-review?page=1&pageSize=24&status=pending' ``` ```ts fetch('https://api.dials.co/v1/picture-review?page=1&pageSize=24&status=pending') ``` ## Use this candidate as the model's picture `POST /picture-review/candidates/{id}/select` Points the model picture at the candidate's URL, clears the crop so the image pipeline redoes it, and marks the picture as reviewed. ### Path parameters - `id` `string` _(required)_ ### Responses #### `200`: Updated. Body (`application/json`): - `id` `string` _(required)_ - `url` `string` _(required)_ - `type` `string` _(required)_ - `croppedUrl` `string | null` _(required)_ - `protected` `boolean` _(required)_ #### `404`: No such candidate. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/picture-review/candidates/clx123/select \ --request POST ``` ```ts fetch('https://api.dials.co/v1/picture-review/candidates/clx123/select', { method: 'POST' }) ``` ## Keep the current picture and stop offering the model for review `POST /picture-review/pictures/{id}/keep` Accepts the picture already on the model and takes it out of the review queue, without changing the image. ### Path parameters - `id` `string` _(required)_ ### Responses #### `200`: Updated. Body (`application/json`): - `id` `string` _(required)_ - `url` `string` _(required)_ - `type` `string` _(required)_ - `croppedUrl` `string | null` _(required)_ - `protected` `boolean` _(required)_ #### `404`: No such picture. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/picture-review/pictures/clx123/keep \ --request POST ``` ```ts fetch('https://api.dials.co/v1/picture-review/pictures/clx123/keep', { method: 'POST' }) ``` ## The picture the site leads with for a model `GET /picture-review/models/{id}/front-picture` Returns the FRONT picture (or the oldest one as fallback: the same rule the public thumbnail follows) with its raw source URL and protected flag. Raw URLs are admin-only, which is why this is not part of the public model payload. ### Path parameters - `id` `string` _(required)_ ### Responses #### `200`: The front picture, or null. Body (`application/json`): - `picture` `object & unknown` _(required)_ #### `404`: No such model. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/picture-review/models/clx123/front-picture ``` ```ts fetch('https://api.dials.co/v1/picture-review/models/clx123/front-picture') ``` ## Replace a model's front picture and/or set its protected flag `PUT /picture-review/models/{id}/front-picture` A new URL clears the crop so the image pipeline redoes it, same handoff as selecting a candidate. When the model has no picture yet, a URL creates the FRONT row. ### Path parameters - `id` `string` _(required)_ ### Request body (required) (`application/json`) - `url` `string ` - `protected` `boolean` _(required)_ ### Responses #### `200`: Updated. Body (`application/json`): - `id` `string` _(required)_ - `url` `string` _(required)_ - `type` `string` _(required)_ - `croppedUrl` `string | null` _(required)_ - `protected` `boolean` _(required)_ #### `400`: Nothing to update. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `404`: No such model. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/picture-review/models/clx123/front-picture \ --request PUT \ --header 'Content-Type: application/json' \ --data '{ "url": "string", "protected": true }' ``` ```ts fetch('https://api.dials.co/v1/picture-review/models/clx123/front-picture', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'string', protected: true }) }) ``` # Rates The USD conversion table listing prices are rendered with. ## USD → display-currency conversion rates `GET /rates` The conversion table listing prices are rendered with: units of each supported display currency per 1 USD, from the ECB reference rates. Published so a caller can convert the same way this API does. ### Responses #### `200`: Units per 1 USD for each display currency (ECB reference rates). Body (`application/json`): - `base` `string` _(required)_ - `rates` `object` _(required)_: Units per 1 USD, keyed by display currency. May be empty when no rate table is available; clients then fall back to USD. ### Example request ```bash curl https://api.dials.co/v1/rates ``` ```ts fetch('https://api.dials.co/v1/rates') ``` # Reconciliation Back office: linking unmatched listings to models. Requires an admin session. ## Unmatched listings, grouped by the identity the provider claims `GET /reconciliation` The reconciliation queue: live listings the brand+reference matcher could not link, grouped by the identity the provider claims, so one decision resolves every listing of the same watch. ### Query parameters - `page` `integer` - `pageSize` `integer` - `q` `string`: Search scraped brand / reference - `ref` `string` - `sort` `string` ### Responses #### `200`: The queue. Body (`application/json`): - `data` `object[]` _(required)_ - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `count` `integer` _(required)_: Live unmatched listings claiming this identity. - `minPriceUSD` `integer | null` _(required)_ - `maxPriceUSD` `integer | null` _(required)_ - `lastSeenAt` `string ` _(required)_ - `brand` `object | null` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `similarModels` `object[]` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `alternateReferences` `string[]` _(required)_ - `slug` `string` _(required)_ - `brandSlug` `string` _(required)_ - `series` `object` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `thumbnailUrl` `string | null` _(required)_ - `description` `string | null` _(required)_ - `releaseYear` `integer | null` _(required)_ - `diameter` `number | null` _(required)_ - `caseMaterials` `string[]` _(required)_ - `bezelMaterials` `string[]` _(required)_ - `dialColors` `string[]` _(required)_ - `bandMaterials` `string[]` _(required)_ - `bandColors` `string[]` _(required)_ - `glass` `string | null` _(required)_ - `listings` `object & object[]` _(required)_ - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_: Total groups matching the filters. - `totalPages` `integer` _(required)_ - `summary` `object` _(required)_ - `listings` `integer` _(required)_: All live unmatched listings. - `withReference` `integer` _(required)_ - `withoutReference` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/reconciliation?page=1&pageSize=24&q=string&ref=with&sort=count' ``` ```ts fetch('https://api.dials.co/v1/reconciliation?page=1&pageSize=24&q=string&ref=with&sort=count') ``` ## Create a catalog model from unmatched listings `POST /reconciliation/models` Creates the model (and its brand/series when they do not exist yet) and links every live unmatched listing claiming the given identity; the group becomes catalogued in one step. ### Request body (required) (`application/json`) - `brandId` `string`: Existing catalog brand - `brandName` `string`: Find-or-create by slug when no brandId - `seriesId` `string`: Existing series of the brand - `seriesName` `string`: Find-or-create under the brand when no seriesId - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `description` `string` - `releaseYear` `integer` - `diameter` `number` - `thickness` `number` - `waterResistance` `number` - `caseMaterials` `string[]` - `bezelMaterials` `string[]` - `dialColors` `string[]` - `bandMaterials` `string[]` - `bandColors` `string[]` - `glass` `string` - `imageUrl` `string `: Seeds the model's FRONT picture; the crop pipeline picks it up from there. - `link` `object` - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `listingIds` `string[]` ### Responses #### `201`: Created and linked. Body (`application/json`): - `model` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `brand` `object | null` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `series` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `linkedListings` `integer` _(required)_ - `createdBrand` `boolean` _(required)_ - `createdSeries` `boolean` _(required)_ #### `400`: Invalid combination. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `404`: brandId / seriesId not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `409`: The model already exists. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/reconciliation/models \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "brandId": "string", "brandName": "string", "seriesId": "string", "seriesName": "string", "name": "string", "referenceNumber": "string", "description": "string", "releaseYear": 0, "diameter": 0, "thickness": 0, "waterResistance": 0, "caseMaterials": [ "string" ], "bezelMaterials": [ "string" ], "dialColors": [ "string" ], "bandMaterials": [ "string" ], "bandColors": [ "string" ], "glass": "string", "imageUrl": "string", "link": { "scrapedBrand": "string", "scrapedReference": "string" }, "listingIds": [ "string" ] }' ``` ```ts fetch('https://api.dials.co/v1/reconciliation/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ brandId: 'string', brandName: 'string', seriesId: 'string', seriesName: 'string', name: 'string', referenceNumber: 'string', description: 'string', releaseYear: 0, diameter: 0, thickness: 0, waterResistance: 0, caseMaterials: ['string'], bezelMaterials: ['string'], dialColors: ['string'], bandMaterials: ['string'], bandColors: ['string'], glass: 'string', imageUrl: 'string', link: { scrapedBrand: 'string', scrapedReference: 'string' }, listingIds: ['string'] }) }) ``` ## Link unmatched listings to an existing model `POST /reconciliation/link` Attaches a group's live unmatched listings to a model that already exists; the answer when the matcher was simply too cautious about a reference variant. ### Request body (required) (`application/json`) - `modelId` `string` _(required)_ - `link` `object` - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `listingIds` `string[]` ### Responses #### `200`: Linked. Body (`application/json`): - `model` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `brand` `object | null` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `series` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `linkedListings` `integer` _(required)_ #### `400`: Invalid combination. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `404`: Model not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/reconciliation/link \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "modelId": "string", "link": { "scrapedBrand": "string", "scrapedReference": "string" }, "listingIds": [ "string" ] }' ``` ```ts fetch('https://api.dials.co/v1/reconciliation/link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId: 'string', link: { scrapedBrand: 'string', scrapedReference: 'string' }, listingIds: ['string'] }) }) ``` ## Update an existing model from unmatched listings, then link them `POST /reconciliation/models/update` Applies the admin's per-attribute picks (catalog value kept unless a listing value was chosen) to a 'may already exist' model, keeps every reference matchable (the old primary and the group's scraped reference become alternates when needed), and links the group's live unmatched listings to the model in the same transaction. ### Request body (required) (`application/json`) - `modelId` `string` _(required)_ - `name` `string` - `referenceNumber` `string`: New primary reference; the old one stays matchable as an alternate. - `description` `string` - `releaseYear` `integer` - `diameter` `number` - `caseMaterials` `string[]` - `bezelMaterials` `string[]` - `dialColors` `string[]` - `bandMaterials` `string[]` - `bandColors` `string[]` - `glass` `string` - `imageUrl` `string `: Replaces the model's FRONT picture (croppedUrl reset so the crop pipeline re-runs); only a model with no picture yet gets a new row. - `link` `object` - `scrapedBrand` `string | null` _(required)_ - `scrapedReference` `string | null` _(required)_ - `listingIds` `string[]` ### Responses #### `200`: Updated and linked. Body (`application/json`): - `model` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `referenceNumber` `string` _(required)_ - `slug` `string` _(required)_ - `brand` `object | null` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `series` `object` _(required)_ - `id` `string` _(required)_ - `name` `string` _(required)_ - `slug` `string` _(required)_ - `linkedListings` `integer` _(required)_ - `updatedFields` `integer` _(required)_ - `addedReferences` `integer` _(required)_: References folded into the alternates so future crawls keep matching. #### `400`: Invalid combination. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. #### `404`: Model not found. Body (`application/json`): - `error` `string` _(required)_: Stable machine-readable error code. - `message` `string`: Human-readable explanation of what went wrong. - `hint` `string`: What to change about the request to make it succeed. - `docs` `string`: Documentation covering this endpoint. ### Example request ```bash curl https://api.dials.co/v1/reconciliation/models/update \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "modelId": "string", "name": "string", "referenceNumber": "string", "description": "string", "releaseYear": 0, "diameter": 0, "caseMaterials": [ "string" ], "bezelMaterials": [ "string" ], "dialColors": [ "string" ], "bandMaterials": [ "string" ], "bandColors": [ "string" ], "glass": "string", "imageUrl": "string", "link": { "scrapedBrand": "string", "scrapedReference": "string" }, "listingIds": [ "string" ] }' ``` ```ts fetch('https://api.dials.co/v1/reconciliation/models/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId: 'string', name: 'string', referenceNumber: 'string', description: 'string', releaseYear: 0, diameter: 0, caseMaterials: ['string'], bezelMaterials: ['string'], dialColors: ['string'], bandMaterials: ['string'], bandColors: ['string'], glass: 'string', imageUrl: 'string', link: { scrapedBrand: 'string', scrapedReference: 'string' }, listingIds: ['string'] }) }) ``` # Sitemap Bulk, cheap enumeration of every catalogue entity that has a page: identity and last-modified date only. ## Every model's URL identity and last-modified date `GET /sitemap/models` Bulk enumeration for building a sitemap or an incremental mirror: brand slug, series slug, model slug and the date the model last changed, up to 10,000 rows a page. Ordered by id so paging stays stable while the catalogue is being written to. ### Query parameters - `page` `integer` - `pageSize` `integer` ### Responses #### `200`: A page of model identities. Body (`application/json`): - `data` `object[]` _(required)_ - `brandSlug` `string` _(required)_ - `seriesSlug` `string` _(required)_ - `slug` `string` _(required)_ - `updatedAt` `string` _(required)_: ISO date (YYYY-MM-DD). - `page` `integer` _(required)_ - `pageSize` `integer` _(required)_ - `total` `integer` _(required)_ - `totalPages` `integer` _(required)_ ### Example request ```bash curl 'https://api.dials.co/v1/sitemap/models?page=1&pageSize=5000' ``` ```ts fetch('https://api.dials.co/v1/sitemap/models?page=1&pageSize=5000') ``` ## Every brand and series, as URL identities `GET /sitemap/catalog` The two small layers above models, unpaginated because there are hundreds rather than tens of thousands of them. ### Responses #### `200`: Every brand and every series. Body (`application/json`): - `brands` `object[]` _(required)_ - `slug` `string` _(required)_ - `updatedAt` `string` _(required)_ - `series` `object[]` _(required)_ - `brandSlug` `string` _(required)_ - `slug` `string` _(required)_ - `updatedAt` `string` _(required)_ ### Example request ```bash curl https://api.dials.co/v1/sitemap/catalog ``` ```ts fetch('https://api.dials.co/v1/sitemap/catalog') ``` ## Every movement, as a URL identity `GET /sitemap/calibers` Calibers that have a page: one with a slug and a maker. The slug carries the brand as a prefix, which is what makes the `/calibers/{brandSlug}/{ref}` URL reversible. ### Responses #### `200`: Every caliber with a page. Body (`application/json`): - `data` `object[]` _(required)_ - `brandSlug` `string` _(required)_ - `slug` `string` _(required)_: The caliber's own slug, which carries its brand as a prefix. - `updatedAt` `string` _(required)_ ### Example request ```bash curl https://api.dials.co/v1/sitemap/calibers ``` ```ts fetch('https://api.dials.co/v1/sitemap/calibers') ```