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

# List saved searches

> **Scope:** `account:read`

What somebody asked to be told about, newest first (`sort=createdAt`, `updatedAt`
or their `-` forms to change that). Each one carries `url`, where the same
search opens on the website, and `newResults`: the watches listed since the search
last notified, among its 100 best matches — the website's "N new results" badge. At
most 100 (the website shows "99+" above 99); `null` when the search service could
not be asked. Counted for the whole page in one request.




## OpenAPI

````yaml /openapi.yaml get /v1/me/saved-searches
openapi: 3.1.0
info:
  title: Wristler API
  version: '1.0'
  summary: Sync your inventory with Wristler and read back the orders it produces.
  description: >
    The Wristler API lets a seller's own systems keep their Wristler inventory
    in step

    and pull the resulting orders. It is used both by software a seller runs
    themselves

    and by products built for many sellers, such as a watch PIM or ERP.


    **Everything speaks [JSON:API 1.0](https://jsonapi.org/).** Requests that
    carry a

    body send `Content-Type: application/vnd.api+json`, and every response —
    including

    every error — comes back in that media type. The one exception is the token

    endpoint below, which follows OAuth 2.0 and is form-encoded.


    **The version is in the path.** `/v1` never changes in a way that breaks a
    working

    client: fields and endpoints are added, never removed or repurposed. A
    change that

    would break you arrives as `/v2`, alongside `/v1`.


    **Scopes are narrow on purpose.** New functionality gets a new scope rather
    than

    widening an existing one, so a token can never quietly gain access the
    seller did

    not approve.


    **A listing belongs to whoever maintains it.** A listing that comes from a
    Connect

    feed is maintained in the seller's own application, and the fields the feed
    supplies

    are overwritten on every import — so changing one here is refused with `409`
    rather

    than accepted and silently undone on the next run. The error names the
    fields. Every

    other listing belongs to the seller, and the API is one of the ways they
    work on it,

    alongside the dashboard and the app.


    **Wondering whether a whole thing is possible?** [What a third-party
    integration

    can do](/partners/capabilities) answers that on one page, as a matrix —
    every endpoint with

    a yes or a no, and a reason for each no.


    **Some of this is ours alone.** The catalogue, messaging and device
    registration

    are reachable only with a Wristler app token, and each of those operations
    says so

    where its scope is named. They are described here because a reference that
    leaves

    out an endpoint that exists is a reference you cannot trust — not because
    they can

    be asked for: the scopes they need are not among the ones the flows below
    offer,

    and requesting one is refused before a seller is ever shown a consent
    screen.


    **There is a rate limit, and it is per token.** 600 requests a minute for an

    integration, 1,800 for the Wristler app signed in, 300 for the app with a
    guest token,

    and 60 for a request with no usable token (counted per IP address).

    Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and
    a

    `429` carries `Retry-After` — honour it rather than retrying sooner. A
    request

    that fails authentication counts too, so retrying a token that came back
    `401`

    only spends what you have left.


    **Retries are safe if you ask for it.** Send an `Idempotency-Key` header on
    a

    write — a UUID per logical operation, not per attempt — and a retry gets the
    first

    attempt's answer back rather than performing the write twice. Every write an

    integration can make honours it, and each operation that does lists the
    header among

    its parameters; the Wristler app's own account and sign-in endpoints do not,
    and

    ignore it. Keys are honoured for 24 hours and are scoped to your client
    (and, for a

    token with a person behind it, to that person), so you cannot collide with
    anyone

    else.


    A replayed response carries `Idempotent-Replay: true`. Reusing a key for a

    *different* request is refused with `422 idempotency_key_reuse`, and
    retrying while

    the first attempt is still running is refused with `409
    idempotency_in_progress` —

    wait and try again. A key longer than 191 characters is `400

    idempotency_key_invalid`. A key is released if we answered with a 5xx, so a
    server

    error can simply be retried.
  contact:
    name: Wristler
    url: https://wristler.eu
    email: support@wristler.eu
servers:
  - url: https://{host}
    description: Wristler
    variables:
      host:
        default: api.wristler.eu
        description: >
          Which environment to call. Build against staging first — it is the
          same

          API with test data, and the credentials you were given are for one

          environment only: a production client id does not work on staging or
          the

          other way round.
        enum:
          - api.wristler.eu
          - api.staging.wristler.eu
          - api.wristler.test
security:
  - oauth2: []
  - bearerAuth: []
tags:
  - name: Authentication
    description: >
      Getting a token, keeping it, and checking it works.


      **A product used by several sellers** (a PIM, an ERP, a marketplace tool)
      uses the

      authorization code flow with PKCE. You send the seller to Wristler, they
      sign in,

      tick which of their businesses you may act for and see exactly which
      scopes you

      are asking for, and you get a code back to exchange for a token.


      The authorization is recorded against the business, not against the person
      who

      approved it: any of their colleagues can withdraw it later, and it
      survives that

      person leaving. It also means you reach exactly the businesses that ticked
      you

      through — never the other businesses the approver happens to work at.


      **What each business allowed is checked per request, not per token.** Your
      token

      carries the union of what every business that connected you agreed to, so
      the

      token alone cannot answer "may I do this here". A shop that granted you
      reading

      answers `403` to a write even when another shop granted you writing.
      Narrowing or

      withdrawing a grant takes effect immediately — there is no token to expire
      first.


      **A seller's own script or cron job** uses the client credentials flow.
      There is

      no browser and no redirect: the seller creates the client themselves in
      their

      Wristler dashboard, and owning it is the authorization.


      The two halves of the code flow are on different hosts, which most clients
      ask

      for separately: the authorization URL is on the website,

      `https://wristler.eu/oauth/authorize`, because it is a page a person looks
      at;

      the token URL is on the API, `https://api.wristler.eu/oauth/token`. Your
      redirect

      URI has to be registered with us beforehand and match exactly, including
      the port.


      Access tokens last one hour, refresh tokens 90 days, and a refresh is
      renewed on

      every use — so an integration that runs at least once a quarter never
      needs the

      seller again. A refresh that comes back `invalid_grant` means the link is
      broken:

      re-authorize rather than retrying.



      ### The Wristler app's version gate


      This applies to our own app and to nothing else, but it applies to every
      request

      it makes, the token endpoint included — an app we are retiring is turned
      away

      before it is handed a working token.


      The app names itself in one header, `X-Wristler-App: ios/2.3.1`. Anything
      else —

      a missing header, a platform we do not know, a version we cannot compare —
      is

      treated as "not our app" and passes straight through, which is what keeps

      partners, a seller's own scripts and this console working.


      Below the minimum, every request answers **426 Upgrade Required** with

      `code: app_update_required` and a `meta` carrying `platform`,
      `installed_version`,

      `minimum_version` and `store_url`. The app switches on the code, never on
      the

      prose.
  - name: Sellers
    description: >
      The businesses your token may act for. Everything an integration does
      lives

      under one of them, which is what keeps a seller's own catalogue apart from
      the

      public one: the same listing id means "is this for sale" in the catalogue
      and

      "what do I have" here, and the address is what says which you asked.


      `/v1/sellers` is the list, and it is the only list: exactly the businesses
      that

      granted you access, so you never have to be told an id out of band. Any
      other

      seller answers `404` — you cannot learn that a business exists.


      **A shop page is made of** `name`, `description`, `sellingPoints` (the two
      lines

      they lead with), `respondsWithin` (in words, as the website says it),

      `memberSince`, `city`, `country`, `logo` (a rendered square), `webUrl`
      (the page on

      the website), the `cover` across the top of it, and `images` — their logo
      and

      whatever else they put on their page. Everything that is their paperwork
      rather

      than their shop answers `null` to anybody who does not act for them, and
      so does

      `visible`.


      Start here. Every address under **Inventory** and **Orders** takes one of
      these

      ids.
  - name: Inventory
    description: >
      One seller's whole stock — drafts, sold listings and everything in review,
      which

      the catalogue deliberately does not give you. This is what you sync
      against.


      **Money is in cents, as an integer**, everywhere. There is no decimal
      anywhere in

      this API: `1450000` is €14,500.00.


      **The status is a word**, never a number. You may set `available`, `sold`,

      `unavailable` and `draft`. `awaiting_review`, `awaiting_update` and

      `unsubscribed_trader` are states a listing reaches on its own — a new
      listing

      lands in review by itself, and billing decides the third.


      **Publishing is sending `available`**, and it goes through review: a
      `draft` or a

      listing in `awaiting_update` lands in `awaiting_review`, and only one that
      was

      reviewed before and taken off sale (`unavailable`) goes straight back to

      `available`. A listing goes to review only with at least one photo and a
      price

      (or `priceOnRequest`) — a draft being published, a new listing created
      into review

      and a `duplicateOf` copy sent as `available` alike; without them it is
      refused with

      `422` on `status`. An unreviewed

      listing cannot be set to `unavailable` or `sold`, which would be a way
      round

      review. A `sold` listing stays sold (`409 listing_sold`); duplicate it
      with

      `duplicateOf` to sell the watch again. The answer carries where it
      actually landed.

      A listing in `awaiting_update` was sent back by a reviewer, and
      `reviewNote`

      says what they want changed; it is `null` in every other state.


      **Photographs come in one of two ways, and each replaces the gallery
      whole.**

      `imageSources` is a list of URLs we fetch — the right road for a system
      that

      already hosts its images, since it saves you posting anything.
      `imageHandles` is

      a list of handles from `POST /v1/uploads`, for a client holding the files

      themselves, such as somebody adding a listing from a phone. A body
      carrying both

      is refused: that would be asking for two galleries.


      **The specifications a listing may carry are a list you can read**:

      `/v1/specifications` gives every attribute and every value it accepts,
      which is the

      same list a write is checked against. A value that is not in it is refused

      rather than stored, because "Steel" has to mean one thing.


      **Give every listing an `externalIdentifier`**: your own id for it, unique
      within

      one seller's inventory. It is how you find your listing again without
      keeping our

      ids, and how a sold item is matched back to your product through the
      order's

      items.


      `reserved` is read-only. Our checkout sets it while a buyer is partway
      through

      paying, and a sync that could clear it would sell the watch twice.


      ### What a watch is


      `specifications` is a flat object of names to words:

      `{"CASE_MATERIAL": "STEEL", "GENDER": "MALE"}`. Both sides come from a
      fixed

      list — the same one a Connect feed uses — and a value we do not know is
      refused

      rather than stored, because the whole point is that "Steel" means one
      thing. The

      error names the field that was wrong.


      `AVAILABILITY`, `STATE`, `CONDITION` and `YEAR_OF_PRODUCTION` are required
      to

      create a listing; without them it cannot be reviewed, let alone sold. On
      an

      update, a name you leave out is left alone — a partial update stays
      partial, so

      you never have to resend the whole specification to avoid wiping it.


      `specificationLabels` is the same thing in the reader's language, from

      `Accept-Language`, keyed by the same names: `{"CASE_MATERIAL": {"label":

      "Kastmateriaal", "value": "Staal"}}`. It is read-only and a companion
      rather than

      a translation of `specifications` itself — that one is what you write, and
      you

      have to be able to send back what you read. A language we do not have
      falls back

      to English rather than failing.


      ### Images


      **Adding.** `imageSources` is up to eight URLs we fetch from, at your
      host. It is

      write-only: what you sent us last week is not the gallery, and answering
      with it

      would invite you to believe otherwise.


      **Reading and changing.** The gallery is a relationship to `uploads`, not
      an array

      of strings, because an array can only be replaced whole. `include=images`
      gives

      you each one with the address we serve it at and the sizes we render, so a
      phone

      does not fetch a hero image to show a thumbnail.


      **`include=cover` is the one photo a card draws.** Same resource type,
      same

      variants, one per listing rather than up to eight — it is the first of
      `images` and

      cannot drift from it. Use it wherever you are drawing a row or a card: on
      a page of

      24 listings that is 24 uploads instead of 79, which was around 70% of the
      answer.

      It works through a relationship too: `watch.cover` on conversations,

      `items.watch.cover` on orders.


      `PATCH /v1/sellers/{sellerId}/watches/{id}/relationships/images` with the
      images

      in the order you want them replaces the gallery: leave one out and it is
      removed,

      reorder them and the listing opens on a different photograph. Images
      removed this

      way are deleted, not orphaned — a file that belongs to nothing is one we
      keep

      paying to store and nobody can reach.


      **They are fetched on a queue, not while you wait.** Eight images from
      your host

      would turn a write into a call that times out on your side and leaves you
      unsure

      whether it landed. So the listing is saved immediately and the gallery
      follows,

      which means a listing read back straight away still shows the old
      `images`. That

      is the honest answer — the new one does not exist yet.


      Sending the same URLs again costs nothing: the set is hashed and an
      unchanged set

      is not re-fetched, so a nightly sync can send its whole record every time.


      Every attribute we set ourselves is read-only, and sending one is refused
      with `403`

      (`read_only_field`) rather than ignored: a field quietly dropped looks
      exactly like one

      that worked. `source.pointer` names it. They are `images`, `reserved`,
      `reviewNote`,

      `translatedDescription`, `descriptionTranslations`, `applicationFee`,
      `feedManaged`,

      `minimumProposal`, `agreedPrice`, `favorited`, `viewerOrder`, `webUrl`,
      `purchasable`,

      `availabilityOnRequest`, `productSafety`, `manufacturerWarranty`,
      `specificationLabels`,

      `createdAt` and `updatedAt`.


      **`feedManaged`** says whether a Connect feed maintains this listing —
      which is

      to say, whether a write to it will be refused. Check it before you sync
      and skip

      those listings, rather than discovering it as a `409` halfway through.


      **`purchasable`** is the question worth asking. `status: available` is not
      the

      same as buyable: a seller whose billing has lapsed still has listings
      marked

      available that nobody can pay for. And a watch whose availability is on
      request

      (`availabilityOnRequest: true`, the `AVAILABILITY` specification
      `ON_REQUEST`) is

      `purchasable: false` too, exactly where the website shows "Request
      availability"

      instead of Buy: start a conversation with `requestAvailability: true`, and
      the

      shop's proposal opens the checkout. It is still for sale otherwise — in
      the

      catalogue and open to offers.


      **`productSafety` and `manufacturerWarranty` are on one listing only**
      (`GET` by

      id), not on a page of them — they are the watch page's "Product safety"
      sheet (GPSR)

      and the info beside "Warranty", read off the brand. Both are `null` when
      the brand

      has nothing on record, which is when the website shows neither.

      `productSafety.manufacturer` / `euResponsible` are `null` when that party
      is not on

      record ("This information is not yet available for this brand"), and

      `euResponsibleRequired: false` means an EU-based manufacturer that needs
      none.

      Countries are ISO codes; the electronic address is `email`, or else
      `website`.

      `manufacturerWarranty.text` is the sentence to show, in your language and
      worded for

      the watch's state — it deliberately never says the warranty still runs.
      Show it as it

      is rather than composing one from `years` and `transferable`.


      **A new listing never starts live.** On a create `status` may be left out
      or be

      `available`, and the listing lands in `awaiting_review` the same way one
      added in the

      dashboard does, going live when we have looked at it; or it may be
      `draft`, which

      only the business sees. Anything else is a `422`. On an update you may set
      `available`, `sold`,

      `unavailable` or `draft` — except on a sold listing, which stays sold

      (`409 listing_sold`) and is duplicated with `duplicateOf` instead.


      **`description` comes back redacted to anybody but the seller it belongs
      to** —

      email addresses replaced — exactly as the website renders it. Sellers put
      contact

      details in descriptions, which is why that redaction exists.
      `translatedDescription`

      and `descriptionTranslations` are redacted the same way; the map was not,
      which meant

      an address taken out of one was readable in the other.


      **`translatedDescription` is the description in the language you asked
      for**, by

      `Accept-Language`, falling back to the language it was written in. Read
      this rather

      than picking a language out of `descriptionTranslations`: the
      eight-language map was

      most of the weight of a watch page — 71 KB against 9 KB for one language,
      on the

      longest description we have.


      **`externalIdentifier` and `dealerProductCode` are only returned to the
      seller

      they belong to.** They are somebody's integration key and somebody's
      internal

      SKU.


      **`applicationFee` is what we charge the business when the listing sells
      at its

      `price`**, in cents: the figure the dashboard shows beside the price.
      Read-only, only for

      the business itself, and `null` to anybody else or without a price. For a
      price that is

      not saved yet, ask `GET /v1/sellers/{sellerId}/application-fee`.


      ### Fields you will not see


      `descriptionTranslations` is returned only to the Wristler app, **and only
      when it is

      asked for by name**: `fields[watches]=...,descriptionTranslations`. The
      translations

      are maintained by a translator after a listing is created, nobody writes
      them here,

      and putting them in front of an integration would make them a contract we
      then have

      to keep. They are not secret — the website serves them to anyone. If you
      want one

      language, which is what every caller so far wanted, read
      `translatedDescription`

      instead.


      ### Choosing what comes back


      `include` adds related resources, `fields[watches]` narrows the
      attributes, and

      `sort` orders them — all listed under each operation. `fields[watches]` is
      worth

      using on a full inventory walk: a listing carries a long description and a

      gallery, and a sync that only needs prices and stock wants neither.


      ### Includes


      `seller`, `watchModel`, `images`, `cover` and `favorite`, comma separated.
      There is deliberately no

      way to include a seller's listings from anywhere else: that is thousands
      of

      records and is asked for at `/v1/sellers/{id}/watches`, where it is paged.


      A listing that comes from a Connect feed is maintained in the seller's own

      application, and the fields that feed supplies are refused here with `409`
      rather

      than accepted and silently undone on the next import. The error names the
      fields.
  - name: Orders
    description: >
      What a seller has to fulfil, under the seller who has to fulfil it.
      Read-only: an

      order records something that already happened.


      **Pull incrementally.** `filter[updatedSince]=2026-09-23T08:00:00Z`
      returns

      everything that moved since your last run; without it you walk the whole
      history

      every time. Combine it with cursor paging and one call per run keeps you
      in step.


      `include=items.watch` gives you the sold listing beside the order, which
      is how

      you recognise your own product: the listing carries the
      `externalIdentifier` you

      gave it. `include=addresses` gives you the name and address the buyer
      entered,

      which is what you need to ship; `kind` says which is which (`SHIPPING` or
      `BILLING`). The buyer's account is not reachable — an order

      gives you what the order needs. Only our own app also reads the buyer's
      `buyerEmail` and

      `buyerPhone`, as the dashboard's order page shows them; a partner reads
      `null`.


      Item names and prices are copies taken at the moment of sale, so a listing
      edited

      or removed afterwards does not change what somebody bought.


      ### Shipping one


      The one thing about an order a seller decides. `PATCH` it with the

      `trackingNumber`; `courier` may be left out when we can recognise it from
      the

      number, and `trackingUrl` when we can build the link for that courier.
      What we

      cannot work out has to be sent, because a number the buyer cannot follow
      is not

      shipping information.


      This is not three fields being filled in: the order moves to `in_transit`
      and the

      buyer is told, exactly as it does from the dashboard. The exception is an
      order

      the seller is bringing to us in person (`awaiting_delivery`), where
      recording a

      courier does not move it and does not tell the buyer a second time.


      The tracking can be corrected while the parcel is on its way: the same
      `PATCH` on

      an order `in_transit` (the buyer is told when it actually changed) or on
      one being

      delivered to us that already has tracking (the buyer is not).


      It needs `orders:write`, so a token that only pulls orders cannot reach
      it.

      Everything else about an order is the record of what happened: the
      amounts, the

      status and the dates are refused with `403` when sent (see the operation
      for the list).


      ### Includes


      `seller`, `items`, `items.watch`, `items.watch.cover` and `addresses`.
      Spelled out rather than derived

      from a depth, which is what stopped `seller.watches` being allowed: that
      would

      have returned every listing a seller has, unpaged, in the middle of an
      order.
  - name: Catalogue
    description: >
      What is for sale, from every seller — the same set the website shows.


      **Scope `catalogue:read`.** A scope of its own, because it describes
      something a

      holder may do rather than who they are — and this is the one thing here
      that could

      reasonably be opened up later, to

      a shop window or a comparison site. That would be a decision about this
      scope

      alone, not about handing anyone a token that acts as our app.


      It is described here because a reference that leaves out an endpoint that
      exists

      is a reference you cannot trust, not because it can be requested today.


      An integration wants the other address anyway: `/v1/sellers/{id}/watches`
      is one

      seller's own stock, drafts and sold listings included, and that is what a
      sync

      runs against. The catalogue is only what is published, from everybody.


      The same listing id means a different thing at each address — "is this for
      sale"

      here, "what do I have" there — which is why they are two addresses rather
      than a

      filter.


      **`agreedPrice`** is what the person holding the token would actually pay:
      the

      price from an offer the seller made them, while it stands, and `null`
      otherwise.

      The checkout honours that offer, so showing the asking price beside a
      button that

      charges less is the kind of thing that reads as a bug rather than as a
      discount.


      **`viewerOrder`** is the order the person holding the token has open on
      this listing

      — processing or awaiting payment — as `{ "id": "123" }`, and `null`
      otherwise or

      without a person behind the token. A listing that is `reserved` with a
      `viewerOrder`

      is reserved for them: show "View your order" (`GET /v1/me/orders/{id}`)
      rather than

      "Reserved".
  - name: Messaging
    description: >
      A thread between one buyer and one seller about one watch, and the
      messages in

      it.


      **Scopes `messages:read` and `messages:write`.** A seller

      answering a buyer is a conversation between two people, and handing that
      to a

      third party is a decision about somebody's correspondence rather than
      about their

      inventory. Widening that later is additive; narrowing it is not. Like the
      account scopes,

      these are not offered by the flows above.


      **The same person is on both ends.** They are the buyer in one thread and
      the

      seller in the next, so `/v1/conversations` returns both: threads they
      bought in,

      and threads belonging to a business they act for. Which end they are on is

      `side`, and `unread` and `archived` are answered from that end. The same
      row

      serialises differently depending on who asked, because in the database
      those are

      separate columns rather than a relationship.


      `archived` and `unread` are what either side may change, each from its own
      end: a

      `PATCH` picks the column from who is asking — a client should not have to
      know there

      are two. The two filters work the same way. (`noReplyNeeded` is the shop's
      alone.)


      **Fetching a thread does not mark it read**: a client fetches one when a

      notification arrives or a list refreshes, which is not the same as
      somebody

      reading it. `PATCH` it with `unread: false` when you have shown it; ask
      the list,

      which carries `meta.unread`, for the badge. Replying turns a thread
      around: read for

      whoever wrote, unread for the other side, and un-archived for both — an
      archived

      thread somebody replies to is not archived any more.


      ### What a message is


      `body` is what to show. The stored column is sanitised HTML that has been
      through

      several hands: a URL redactor at write time, a `%sellerName%` token a
      queued

      translator substitutes later, and for a price proposal a block of markup
      wrapping

      a signed checkout link. The website runs all of that through a purifier on
      every

      render and so do we, so `body` is what a visitor sees.


      `author` is `buyer`, `seller` or `system`. A message with no author is
      ours —

      there is one, the payment confirmation — and it is rendered differently.
      `mine`

      says whether the token holder wrote it.


      `removed` marks a message a moderator took out. The message stays in place
      with

      `body: null`, because a hole nothing explains reads as a bug.


      `priceProposal` is an offer, from either side, as figures — null on an
      ordinary

      message. `amount` in cents, `currency`, and `from`, which is `buyer` or
      `seller`:

      the two are not the same kind of thing. A seller's offer is binding by
      being sent;

      a buyer's is a request worth nothing at a checkout until it is accepted.


      `status` is `pending`, `accepted`, `withdrawn` or `expired` — or
      `refused`, only on an

      offer answered before offers stopped being refused — and `payable` says
      the only

      thing a checkout cares about. `expiresAt` is null on a buyer's offer — a
      question

      has no clock — and 48 hours away on a seller's. A buyer's offer still
      `pending` becomes

      `expired` when the seller sends a proposal of their own in the thread:
      that is the

      seller's answer to it.

      `state` is what the website's card says about a **seller's** proposal, and
      null on

      a buyer's offer: `open`; `expiring` (under 6 hours left); `expired`;
      `superseded`

      (expired, and a newer proposal or offer was sent in the thread since);
      `unavailable`

      (the watch is sold, offline or deleted, so no amount buys it); `paid` (an
      order was

      paid at it — this wins over everything, also after the 48 hours). Draw the
      card from

      `state`; `status` stays for answering offers.

      `acceptedAs` is the seller's proposal that accepting a buyer's offer
      produced, and

      `generatedBody` says the sentence beside the offer is ours rather than the
      buyer's.

      `shippingCosts` is what the buyer pays for shipping on top of `amount`, in
      cents: what

      the seller set with their proposal, otherwise the listing's. `saving` and

      `savingPercentage` are the website card's "You save €x (−y%)": `amount`
      plus shipping

      against the listing's price plus shipping, in cents and as a whole
      percentage — null

      when there is nothing saved or no public price to save on. Show them as
      they are rather

      than working them out: the rounding is the website's.


      Sending one is the same shape either way: `priceProposal: {"amount": …}`
      on a new

      message, with `body` optional beside a buyer's offer (a seller's proposal
      carries no

      words). A seller may add `shippingCosts`. The expiry, the wording and the
      signed

      link are ours to build, because they are what a buyer agrees to. The same

      attribute on `POST /v1/conversations` opens a thread with an offer in it.


      `translatedBody` is a machine translation, made once when the message was
      written,

      into the **account language** (`locale` on `/v1/me`) of the side that
      reads it: the

      buyer for what the shop and the system write, the shop for what the buyer
      writes. Only

      that side gets it; for the author it is `null`. Show `translatedBody` when
      there is one,

      `body` otherwise. `translationSourceLocale` is the language it was written
      in (for

      "translated from German"; `null` on older messages), `translationLocale`
      the one it was

      translated into, and `translationPending` is true while one is still on
      its way — show

      the original until then. No translation is kept when the message was
      already in the

      reader's language. Returned to the app only, as a behaviour we may change
      rather than

      as a promise.
  - name: Uploads
    description: >
      Files, and the handle that stands for one.


      A file arrives in two steps, because those are two different requests: the
      file

      itself is bytes, and what it belongs to is a JSON:API document. So you
      send the

      file to `POST /v1/uploads`, get a **handle** back, and name that handle in
      the

      thing the file belongs to — `attachmentHandles` on a message,
      `imageHandles` on a

      listing. Until it is named, the file belongs to nobody.


      **It takes the file either way round**: the bytes as
      `multipart/form-data`, or a

      `sourceUrl` we fetch. A client with a photograph from a camera and one
      with a URL

      from a shop both end up holding the same kind of handle, so everything
      after that

      first call is one road.


      The handle is the storage service's own path for the file. It is the only
      name

      either of us has for it, and it is what a message carries: a message's

      `attachments` relationship is what comes back, and `include=attachments`
      gives

      you the files themselves, each with the sizes we render.


      **Scope `uploads:write`.**


      **A listing's photographs take the same two steps** when you hold the
      files:

      upload each one, then send the handles as `imageHandles` on the listing.
      That is

      what an app adding a listing from a phone does.


      If your images are already on a server of your own, do not post them to us
      at

      all: give the listing `imageSources`, the URLs they live at, and we fetch
      them.

      One call instead of nine, and an unchanged set on the next sync costs
      nothing

      because we hash what we already hold. Either way the gallery that results
      is

      under **Inventory**, where the listing is.


      ### The two resources


      **`pending-uploads`** — what `POST /v1/uploads` answers with. A file we
      are

      holding, not yet attached to anything.


      | | |

      | --- | --- |

      | `handle` | The name to attach it by. This is the only field you send
      back. |

      | `fileType` | `image` or `video`, worked out from the file. |

      | `createdAt` | |


      It has no address of its own, so the document carries no `self` link: it
      is

      written once, named once, and then it is an attachment. A handle nobody
      names is

      simply never attached.


      **`uploads`** — a file that is attached: a listing's gallery image, or a
      message

      attachment. Read-only, and reached through the thing it belongs to
      (`images` on a

      listing, `attachments` on a message).


      | | |

      | --- | --- |

      | `url` | Where we serve it. Not where it came from — that was theirs,
      this is ours, and only one of the two still resolves. |

      | `handle` | The name it is attached by: what `imageHandles` takes, so a
      gallery can be sent back with one photo added without uploading the others
      again. |

      | `variants` | `thumbnail` (266x192), `square` (312), `squareLarge` (506),
      `square800` (800, WebP), `squareXLarge` (1440) and `hero` (a 1920x384
      banner). Picking one saves fetching a large image to show a thumbnail,
      which on a phone is the difference that matters. |

      | `position` | Where it sits in a gallery; `1` is the one a listing opens
      on. |

      | `createdAt` | |
  - name: Account
    description: >
      The person behind the token: their own details, and the state of their
      address.

      Scope `account:read` to read, `account:write` to change.


      What hangs off an account has a heading of its own — the address book,
      what they

      put aside, the searches they want to hear about, what they bought, and
      what

      protects the account.


      A partner never needs this. They act for a business, and a business does
      not have

      a home address to ship a watch to — the addresses on an order are copies
      taken at

      the moment of sale and live under the order, read-only.


      **What is never read back**: a password, a second factor's secret, a
      recovery

      code. A secret and a recovery code are handed over once, at the moment
      they are

      made, and after that `mfaEnabled` on `/v1/me` is all there is to read —
      which is

      all a screen needs.


      Everything that changes what protects the account asks for the password
      again. A

      token lifted off a device must not be enough to take somebody's second
      factor

      away, which is the one thing it exists to prevent.
  - name: Account (Addresses)
    description: >
      The address book a checkout fills itself from. Scope `account:read` to
      read,

      `account:write` to change.


      A partner never needs this. They act for a business, and a business does
      not

      have a home address to ship a watch to — the addresses on an order are
      copies

      taken at the moment of sale and live under the order, read-only.
  - name: Account (Favorites)
    description: >
      Watches somebody put aside. Scope `account:read` to read, `account:write`
      to change.


      A listing carries `favorited` for the heart on a card, and

      `?include=favorite` gives the viewer's own favorite so that heart can turn

      itself off — removing one needs its id.
  - name: Account (Blocked businesses)
    description: >
      Businesses somebody wants nothing more from. Scope `account:read` to read,

      `account:write` to change.


      A block is against the **shop**, not against whoever wrote: a business is
      staffed

      by several accounts, so blocking the person who sent the message would
      leave their

      colleagues able to send the next one.


      It closes every thread between the two, in both directions, and stops a
      new one

      being opened — a thread carries `blocked: true` so a client knows not to
      draw a

      reply box. It does **not** hide the shop's watches: somebody who blocked a
      shop

      after an argument may still want the watch they were arguing about.
  - name: Messaging (Quick replies)
    description: >
      A shop's canned answers, kept per shop so everybody at it -- in the app
      and in the

      website's seller chat -- has the same ones. Scopes `messages:read` and
      `messages:write`,

      under the shop at `/v1/sellers/{sellerId}/quick-replies`. Picking one puts
      its `body` in

      the message box; sending is an ordinary message.
  - name: Messaging (Blocked buyers)
    description: >
      Buyers a shop wants nothing more from: the other direction of

      `/v1/me/blocks`. Scopes `messages:read` and `messages:write`, under the
      shop at

      `/v1/sellers/{sellerId}/buyer-blocks`.


      Either block closes every thread between the two, both ways. A thread says

      `blocked: true`, and `blockedByYou` says whether it was the caller's side
      -- the buyer

      in their own thread, the shop for its staff -- so a client knows whether
      to offer an

      unblock. A refused message carries the same answer in `meta.blockedByYou`.


      A thread also says `blocked: true`, with `blockedByYou: false` unless the
      caller's side

      blocked as well, when Wristler blocked the buyer's account: every thread
      of that buyer

      is closed, and there is nothing for either side to undo.
  - name: Messaging (Reports)
    description: >
      Telling us something in a thread is wrong. Either side of a thread may
      report it, with a

      reason from `/v1/report-reasons`. Scopes `messages:read` and
      `messages:write`. Reporting

      is not blocking: that is `/v1/me/blocks` for a buyer and

      `/v1/sellers/{sellerId}/buyer-blocks` for a shop.
  - name: Account (Saved searches)
    description: >
      A search somebody wants to be told about, in the same words the catalogue
      is

      searched with. Scope `account:read` to read, `account:write` to change.
  - name: Account (Orders)
    description: |
      What somebody bought, read by the person who bought it — the other side of
      `/v1/sellers/{id}/orders`. Scope `account:read`.
  - name: Account (Checkout)
    description: >
      Buying a watch: the order, and the payment. Scope `checkout:write` —
      paying is not

      the same permission as editing an address, which is why it is not part of

      `account:write`.


      **Nothing here takes an amount.** What something costs is worked out from
      the

      watch, the offer behind it and the method chosen, and the payment is
      created for

      exactly that. A price in a request body would be a number we have no way
      to check.


      **The method list is ours, and the app draws it.** Every method carries
      its own fee

      (`fee`), and on a watch that is the difference between nothing and a few
      hundred

      euros — which is what somebody makes their choice on. So the order answers
      with

      every method its seller offers, in the order this language shows them,
      each with

      what it costs and what that makes the total. The app shows that list and
      posts back

      a `code`; it never adds anything up.


      **Who confirms the payment** depends on the method, and it is the one
      thing worth

      reading twice:


      - a **bank transfer** (`customer_balance`) is confirmed by us. It is the
      method
        Wristler prefers and most of this money arrives through, and there is nothing to
        fill in: the answer carries the account to transfer into, and the app shows text.
      - **iDEAL, Bancontact, EPS and Przelewy24** send the buyer to their bank.
      Name a
        `returnUrl` and we confirm the payment and answer with the address to send them
        to — a whole checkout with no payment SDK on the device. Leave it out and the
        client confirms it itself with `clientSecret`.
      - a **card** is never confirmed here, because the card is not ours to
      have. The
        client secret comes back and the device does the rest, 3-D Secure included.

      Apple Pay and Google Pay are part of the card: Stripe's hosted card page
      offers them where

      the device has one, at the card's service costs, unless the environment
      switched them off

      with a payment method configuration. Send `paymentMethod: card` either
      way.
  - name: Account (Devices)
    description: >
      An install of the Wristler app that can receive a push notification. Scope

      `account:write` — a device belongs to whoever is signed into it, which is
      why it

      lives under `/v1/me` with the rest of what an account owns.


      The app registers itself after signing in, and again whenever the platform
      hands

      it a new token — which Apple and Google both do on reinstall, on restore,
      and

      occasionally on their own.


      Sending is built, direct to APNs and FCM. `locale` on the registration is
      what a push

      is written in -- the device's language, not the account's -- so send it
      and keep it

      current.


      **Send `apnsEnvironment` on iOS if you can read it** (`development` or
      `production`,

      from the provisioning profile; `sandbox` is accepted as a synonym for the
      first). There

      is no single right answer for us to configure: a simulator build is on
      Apple's sandbox

      and an internal build is on production, because Ad Hoc provisioning signs
      with

      `aps-environment=production` however much the build is called
      "development". Leave it

      out and the first notification finds out -- the configured default is
      tried, a

      `BadDeviceToken` is taken as the question it is, the other host is tried
      once, and the

      answer is written on the registration. That works, and costs one refused
      request per

      handset.


      **Send `appId`**: the app's bundle id / package (`eu.wristler.app`,

      `eu.wristler.app.staging` or `eu.wristler.app.dev`). The three apps share
      one Firebase

      project, so the token alone does not say which app it belongs to. A server
      that is not

      production pushes only to devices of its own app, and logs a push for any
      other device

      -- including one that never sent `appId` -- so a test server can never
      reach somebody's

      store app. Production pushes to every device.


      A registration is deleted only when Apple says `Unregistered`.
      `BadDeviceToken` does

      **not** delete it: Apple returns that both for a token that was never ours
      and for a

      good token on the wrong host, and deleting on it would mean one wrong
      environment wiping

      every registration while looking like a fleet of stale devices.


      What somebody is told about is at `/v1/me/notification-preferences`, and
      everything they

      were told is at `/v1/me/notifications`.
  - name: Account (Notifications)
    description: >
      The bell: everything somebody was told, at `/v1/me/notifications`, the
      same list the

      website's inbox shows. Scope `account:read` to read it, `account:write` to
      mark entries

      read or clear them -- one at a time, or the whole bell in one request.
  - name: Account (Security)
    description: >
      The second factor: turning it on, turning it off, and replacing the
      recovery

      code. Scope `account-security:write`.


      **What is never read back**: a password, a second factor's secret, a
      recovery

      code. A secret and a recovery code are handed over once, at the moment
      they are

      made, and after that `mfaEnabled` on `/v1/me` is all there is to read —
      which is

      all a screen needs.


      Everything that changes what protects the account asks for the password
      again. A

      token lifted off a device must not be enough to take somebody's second
      factor

      away, which is the one thing it exists to prevent.
  - name: Content (Pages)
    description: >
      Everything that is neither a listing nor an article: how it works, who we
      are,

      the terms, the contact page. Scope `content:read`.


      `template` says which layout the website draws each one with, so an app
      can lay

      out a contact page differently from a terms page without matching on the
      slug.


      Content comes back as `blocks` — a `kind` and named fields rather than the
      page

      builder's own keys, so a client is not built on `content_left` and
      `list_3`. A

      `kind` you do not recognise should be skipped; one we have not taught the
      API

      about is left out rather than guessed at. `blocks` is `null` in a list and
      filled

      in when the page itself is asked for.


      One language per request, from `Accept-Language`. Nothing unpublished, by
      id or

      otherwise.
  - name: Content (Filter pages)
    description: >
      What the catalogue is organised into — "Rolex", "Rolex Air-King", "Omega
      under

      €5,000" — and how most people arrive. Scope `content:read`.


      Each one carries `search`, the listing it stands for in the catalogue's
      own

      words: hand `search.words` and `search.filters` back to `/v1/watches` and
      you

      have the page. There is no second way to say "every Rolex", so there is
      nothing

      that can fall out of step with the first.


      `filter[q]` is the autocomplete, ranked by the same index and the same
      relevance

      threshold as the search bar on the website.


      Content comes back as `blocks` — a `kind` and named fields rather than the
      page

      builder's own keys, so a client is not built on `content_left` and
      `list_3`. A

      `kind` you do not recognise should be skipped; one we have not taught the
      API

      about is left out rather than guessed at. `blocks` is `null` in a list and
      filled

      in when the page itself is asked for.


      One language per request, from `Accept-Language`. Nothing unpublished, by
      id or

      otherwise.
  - name: Content (FAQ)
    description: >
      The help centre: categories, and the answers filed under them. Scope

      `content:read`.


      An answer is a question and one body of HTML rather than blocks, because
      it is

      written in a single editor field and a one-block list is a wrapper nobody
      wants

      to unwrap. A category with `?include=articles` is the whole screen in one
      call,

      which is why the categories are not paged.


      One language per request, from `Accept-Language`, and nothing unpublished
      — a

      category's drafts are listed in the administration on purpose and never
      here.
  - name: Content (Insights)
    description: >
      What we write, film and record, and the words they are filed under. Scope

      `content:read`.


      `filter[q]` is the other half of the website's dropdown, which offers
      filter

      pages and Insights together. `/v1/article-tags` is every tag in one call,
      which

      is what an app draws its chips from and what `filter[tag]` takes.


      Content comes back as `blocks` — a `kind` and named fields rather than the
      page

      builder's own keys, so a client is not built on `content_left` and
      `list_3`. A

      `kind` you do not recognise should be skipped; one we have not taught the
      API

      about is left out rather than guessed at. `blocks` is `null` in a list and
      filled

      in when the page itself is asked for.


      One language per request, from `Accept-Language`. Nothing unpublished, by
      id or

      otherwise.
paths:
  /v1/me/saved-searches:
    get:
      tags:
        - Account (Saved searches)
      summary: List saved searches
      description: >
        **Scope:** `account:read`


        What somebody asked to be told about, newest first (`sort=createdAt`,
        `updatedAt`

        or their `-` forms to change that). Each one carries `url`, where the
        same

        search opens on the website, and `newResults`: the watches listed since
        the search

        last notified, among its 100 best matches — the website's "N new
        results" badge. At

        most 100 (the website shows "99+" above 99); `null` when the search
        service could

        not be asked. Counted for the whole page in one request.
      parameters:
        - name: page[number]
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: page[size]
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 25
      responses:
        '200':
          description: The searches.
          content:
            application/vnd.api+json:
              schema:
                $ref: '#/components/schemas/ResourceCollection'
              examples:
                default:
                  $ref: '#/components/examples/savedSearches'
        '403':
          $ref: '#/components/responses/MissingScope'
      security:
        - oauth2: []
        - bearerAuth: []
components:
  schemas:
    ResourceCollection:
      type: object
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Resource'
        included:
          type: array
          items:
            $ref: '#/components/schemas/Resource'
        links:
          type: object
          description: Present when the answer is a page.
          properties:
            first:
              type: string
            prev:
              type: string
              description: Absent on the first page.
            next:
              type: string
              description: Absent on the last page. That is how you know to stop.
            last:
              type: string
              description: Numbered pages only; a cursor has no last page.
        meta:
          type: object
          description: >
            `page` when the answer is a page: `from`, `to`, `perPage` and
            `hasMore` for a

            cursor (`from` and `to` are cursors, not numbers), or `currentPage`,
            `from`,

            `lastPage`, `perPage`, `to` and `total` for numbered pages. Some
            lists add

            their own beside it, such as `statusCounts`, `unread` or `facets`.
          additionalProperties: true
    Resource:
      type: object
      required:
        - type
        - id
      properties:
        type:
          type: string
          examples:
            - watches
        id:
          type: string
        attributes:
          type: object
          additionalProperties: true
        relationships:
          type: object
          additionalProperties: true
        links:
          type: object
          properties:
            self:
              type: string
              description: >-
                Where this exact representation lives. Always one you can
                follow.
    ErrorDocument:
      type: object
      description: Every error outside the token endpoint, in JSON:API's error format.
      required:
        - errors
      properties:
        jsonapi:
          type: object
          properties:
            version:
              type: string
              const: '1.0'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'
    Error:
      type: object
      properties:
        status:
          type: string
          description: The HTTP status, as a string.
          examples:
            - '404'
        code:
          type: string
          description: >
            What went wrong, as a stable word to branch on (`watch_reserved`,

            `missing_scope`, `read_only_field`…). Absent on a plain validation
            failure, which

            names its field in `source.pointer` instead.
          examples:
            - watch_reserved
        title:
          type: string
          examples:
            - Not Found
        detail:
          type: string
        meta:
          type: object
          description: >-
            What a refusal says beside its words, when it has more to say, such
            as `meta.orders` or `meta.blockedByYou`.
          additionalProperties: true
        source:
          type: object
          description: Where in your request the problem is.
          properties:
            pointer:
              type: string
              examples:
                - /data/attributes/reference
            parameter:
              type: string
  examples:
    savedSearches:
      summary: Every search somebody saved
      value:
        data:
          - type: saved-searches
            id: '204'
            attributes:
              name: Submariner under €14k
              words: submariner
              filters:
                brand:
                  - Rolex
                price:
                  min: 0
                  max: 1400000
              interval: daily
              push: true
              url: >-
                https://wristler.eu/watches?filters%5Bbrand%5D%5B0%5D=Rolex&filters%5Bprice%5D%5Bmin%5D=0&filters%5Bprice%5D%5Bmax%5D=14000&query=submariner&sortBy=watches%3Acreated_at%3Adesc
              newResults: 3
              createdAt: '2026-09-01T05:22:00.000000Z'
              updatedAt: '2026-09-01T05:22:00.000000Z'
  responses:
    MissingScope:
      description: The token is valid but was not granted the scope this needs.
      content:
        application/vnd.api+json:
          schema:
            $ref: '#/components/schemas/ErrorDocument'
  securitySchemes:
    oauth2:
      type: oauth2
      description: A bearer token obtained through one of the two flows below.
      flows:
        authorizationCode:
          authorizationUrl: https://wristler.eu/oauth/authorize
          tokenUrl: https://api.wristler.eu/oauth/token
          refreshUrl: https://api.wristler.eu/oauth/token
          scopes:
            watches:read: Read the watches in your inventory
            watches:write: Add, update and remove watches in your inventory
            orders:read: Read your orders
            orders:write: Record that you have shipped an order
        clientCredentials:
          tokenUrl: https://api.wristler.eu/oauth/token
          refreshUrl: https://api.wristler.eu/oauth/token
          scopes:
            watches:read: Read the watches in your inventory
            watches:write: Add, update and remove watches in your inventory
            orders:read: Read your orders
            orders:write: Record that you have shipped an order
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        The access token one of the flows below produced, sent as

        `Authorization: Bearer <token>`. It is the same credential the `oauth2`

        scheme describes — named separately so a console can offer a field to
        paste

        it into, since the flow itself runs outside the console.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.