> ## 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.

# Building the Wristler app

> What the app has to do, in the order it has to do it.

What the app has to do, in the order it has to do it. Everything here is
implemented and testable today unless a section says otherwise.

You will be given a `client_id` for the app. There is **no client secret**: a
secret shipped in a binary is out within the hour, so the app is a public client
and the lock is on our side.

* **Host:** `https://api.wristler.eu` — please build against the staging host
  first, which we will give you separately.
* **Format:** [JSON:API 1.0](https://jsonapi.org/). Send and accept
  `application/vnd.api+json`. The one exception is `/oauth/token`, which follows
  OAuth 2.0 and is form-encoded.
* **Version:** `/v1`. We add fields and endpoints there, never remove or
  repurpose them. A change that would break you arrives as `/v2`.
* **The endpoint reference** is the *API reference* tab of this site, with a
  console you can call from. Everything the app reads and writes is there — the
  catalogue, a seller's shop, orders, messaging, devices — each operation naming
  the scope it needs. This guide is the part that is not: signing in, staying
  signed in, and the two things every request has to get right.

***

## 1. Identify the app on every request

```
X-Wristler-App: ios/2.4.0
```

Platform is `ios` or `android`, the version is your release version in dotted
digits. Send it on **every** request, including the token requests below.

It is also what we record as where something was made, with the version that made it —
an account, a conversation, a message, an order, a saved search, a favourite, a listing, a
report or a block says `app:ios` or `app:android` rather than the website — and what tells
the sign-in list which platform a sign-in was on. A request to the API without it is
recorded as an integration (`api`), not as the app.

This is how we can cut off a release that turns out to be broken without waiting
for everyone to update. Which brings us to:

## 2. Handle `426 Upgrade Required`

Any request can come back with:

```json theme={null}
{ "errors": [{
  "status": "426",
  "code": "app_update_required",
  "title": "Upgrade Required",
  "meta": {
    "platform": "ios",
    "installed_version": "2.4.0",
    "minimum_version": "2.4.1",
    "store_url": "https://apps.apple.com/…"
  }
}]}
```

Switch on `code`, never on the prose — the wording will change, the code will
not. Show a blocking screen with a button to `store_url`. There is no way past
it: signing in is gated too, on purpose.

We will only ever raise the minimum after telling you. It exists for the case
where a release is actively causing damage.

## 3. Sign in

`POST /oauth/token`, **form-encoded** (`application/x-www-form-urlencoded`). A
JSON body arrives empty and is rejected.

### Email and password

| Field | |
| - | - |
| `grant_type` | `urn:wristler:native-login` |
| `client_id` | yours |
| `username` | the email address |
| `password` | |
| `scope` | space-separated, see below |

There are three outcomes and the app must handle all three.

**Success** — an ordinary OAuth token response:

```json theme={null}
{ "token_type": "Bearer", "expires_in": 3600,
  "access_token": "…", "refresh_token": "…" }
```

**Two-factor required** — `401`, with `error: "mfa_required"`:

```json theme={null}
{ "error": "mfa_required", "challenge": "…", "challenge_expires_in": 300 }
```

Ask for the six-digit code and post again, with the challenge instead of the
credentials. Do **not** keep the password around for this:

| Field | |
| - | - |
| `grant_type` | `urn:wristler:native-login` |
| `client_id` | yours |
| `challenge` | from the response above |
| `otp` | the code from their authenticator, or their recovery code |

> [!NOTE]
>
> **No `scope` on this call.** The challenge carries what you asked for at the
> password step, and that is what the token gets. Sending `scope` again is harmless
> — it is ignored when the password step named any — so ask for everything there.

The challenge lasts five minutes.

**A wrong code** — `401`, with `error: "invalid_otp"`:

```json theme={null}
{ "error": "invalid_otp", "attempts_remaining": 4 }
```

The challenge is still good. Let them type another code and post the same challenge
again; do not send them back to the password screen, because you do not have the
password any more and neither should you.

After the last attempt the challenge is destroyed and the answer becomes
`400 invalid_grant` — the same as an expired or already-used one, which is where you
do start again from the password step.

**Account blocked** — `403`, with `error: "account_blocked"`, only when the password
was right for an account Wristler blocked. It comes at the password step, before any
code is asked for — or at the code step, if the block came in between:

```json theme={null}
{ "error": "account_blocked",
  "error_description": "Your account has been blocked by Wristler. Please contact us if you believe this is a mistake.",
  "contact_url": "https://wristler.eu/contact" }
```

Show `error_description` — it is in the language of `Accept-Language` — and a way to
`contact_url`. Do not offer to try again; the website says the same.

**Failure** — `400` with `error: "invalid_grant"`. Deliberately the same answer
for a wrong password and an unknown address: it must not be possible to find out
which email addresses have an account here. Show one message for all of them. A
blocked account with a wrong password answers this way too.

Both steps are rate limited. A `429` carries `Retry-After`.

### Sign in with Apple, Sign in with Google

Do the sign-in with the platform SDK, then hand us the ID token it returns:

| Field | |
| - | - |
| `grant_type` | `urn:wristler:social` |
| `client_id` | yours |
| `provider` | `apple` or `google` |
| `id_token` | the ID token from the SDK |
| `nonce` | the nonce you generated for that sign-in — required |
| `given_name`, `family_name` | optional; used when this sign-in creates the account, or to fill in a name the account does not have yet |
| `authorization_code` | Apple only, optional: the SDK's `authorizationCode`. We exchange it for the refresh token Apple revokes when the account is deleted. A code we cannot exchange never fails the sign-in. |
| `scope` | space-separated |

**Generate a fresh nonce per sign-in and pass it to the SDK.** We compare it with
the token's `nonce` claim exactly as sent — no hashing on our side, so give the
provider the same string you give us. A token whose nonce does not match is
refused, and a request without one is `400 invalid_request`: that check is what
stops a token obtained elsewhere from being replayed here.

**Apple tells you the person's name once**, on their first authorization, and
never puts it in the token. Pass it on as `given_name` and `family_name`, or the
account is created without a name. An existing account is never renamed: a name sent
later only fills in a first or last name that is still empty.

There is **no second-factor step** here: Apple and Google have already asked for
theirs before the token reaches us.

#### Apple on Android

Android has no native Sign in with Apple, so the app uses Apple's web flow in the
system browser and Apple hands the result to us:

1. Open `https://appleid.apple.com/auth/authorize` with our Services ID as
   `client_id`, `response_type=code id_token`, `response_mode=form_post`,
   `scope=name email`, your `nonce` and a `state`, and
   `redirect_uri=https://api.wristler.eu/oauth/apple/return`.
2. We answer Apple's post with a `303` to the app's App Link, carrying
   `handoff` and your `state` — or `error` and `state` when the person cancelled.
   The ID token itself never travels in a URL. Check `state`.
3. Exchange the code like any other social sign-in, with `handoff` in place of
   `id_token`, and the same `nonce`:

| Field | |
| - | - |
| `grant_type` | `urn:wristler:social` |
| `client_id` | yours |
| `provider` | `apple` |
| `handoff` | from the App Link |
| `nonce` | the nonce you gave Apple |

A code lasts five minutes and works once. The name Apple posts on the first
authorization travels with it, so there is nothing to pass on here.

Everything that can go wrong comes back as `400 invalid_grant`, for the same
reason as above — except an account Wristler blocked, which Apple or Google just
vouched for: that is `403 account_blocked`, as described above. One case worth knowing: if the email address already belongs to
an account we have not verified, we refuse rather than link the two. Send the
person to email-and-password sign-in.

### Before anybody signs in

The catalogue, a shop page and the pages people read are public on the website,
so the app shows them without an account too. It does that with a **guest
token**:

| Field | |
| - | - |
| `grant_type` | `urn:wristler:guest` |
| `client_id` | yours |
| `scope` | optional; `catalogue:read content:read` is what you get, and all you may ask for |

The response is an ordinary token response without a `refresh_token`. A guest
token belongs to nobody and reads only what the website shows everybody:

| Works | Does not |
| - | - |
| `GET /v1/watches`, `/v1/watches/{id}`, `/v1/watches/{id}/related`, `/v1/brands`, `/v1/shipping-countries` | `/v1/me`, favourites, messages, orders |
| `GET /v1/sellers` and `/v1/sellers/{id}` — the shop directory and a shop page | a seller's statistics, and anything under a seller, its listings included |
| `GET /v1/specifications`, `/v1/watch-models` | anything that writes |
| `GET /v1/filter-pages`, `/v1/articles`, `/v1/article-tags`, `/v1/pages`, `/v1/faq-categories`, `/v1/faq-articles`, `/v1/personal-shopper` | |

* **Outside what it reads, a guest hears `403` with `code: "sign_in_required"`**,
  never `401`. A guest token is a good token; the person just has to sign in
  first. So a `401` keeps meaning what it always means — refresh, or sign out.
* **It lasts one hour.** There is nothing to refresh: ask for a new one when it
  runs out, on the clock like any other token.
* **Signing in replaces it.** It is not upgraded; drop it once the sign-in above
  succeeds, and ask for a new one after signing out.
* **Keep the one you have.** Tokens are handed out per address with a limit, so
  ask once per session rather than per screen. Past it, `429` with
  `error: "too_many_requests"`.
* A guest token is rate limited more tightly than a signed-in one — plenty for a
  person browsing, not for crawling.

### Scopes

Ask for what you need and nothing more:

| Scope | |
| - | - |
| `account:read` | Read the person's own things: addresses, favorites, notifications and their preferences, blocks, saved searches, orders |
| `account:write` | Change any of those, the account itself (`PATCH /v1/me`, closing it), and devices — reading them too |
| `account-security:write` | The second factor, the recovery code, linked Apple and Google sign-ins, recent sign-ins, signing out, resending the verification mail |
| `checkout:write` | Placing an order and paying for it |
| `catalogue:read` | What is for sale, from every seller |
| `content:read` | The pages, Insights and the help centre |
| `messages:read`, `messages:write` | Conversations |
| `uploads:write` | Sending us a file |
| `watches:read`, `watches:write` | A business's own inventory |
| `orders:read`, `orders:write` | A business's own orders |
| `business:write` | A business's own details |

`GET /v1/me` itself needs no scope beyond a signed-in token.

## 4. Keep the token

Access tokens last **one hour**, refresh tokens **90 days**, and a refresh is
renewed every time you use it — so an app that is opened at least once a quarter
never has to ask for a password again.

```
grant_type=refresh_token&client_id=…&refresh_token=…
```

Refresh **before** a call fails, on the clock, rather than retrying on a 401.
A refresh that comes back `invalid_grant` means the session is genuinely over:
clear the tokens and show the sign-in screen. Do not retry it.

## 5. Register the device for push

Once signed in, and again whenever the platform hands you a new push token:

`POST /v1/me/devices`

```json theme={null}
{ "data": { "type": "devices", "attributes": {
  "platform": "ios",
  "pushToken": "…",
  "appVersion": "2.4.0",
  "name": "Jeffrey's iPhone",
  "locale": "nl"
}}}
```

**Just send it every time.** Registering the same token again is not an error —
it updates what we have (`200`), a token we have not seen creates a registration
(`201`). You do not have to remember an id, check whether you registered before,
or clean up after a reinstall.

On sign-out, `DELETE /v1/me/devices/{id}` if you kept the id; otherwise simply stop
sending, and the registration moves along with the token the next time someone
signs in on that handset.

`GET /v1/me/devices` lists this user's own registrations. `pushToken` is never
returned.

> Sending is built: direct to APNs and FCM. Send `locale` with the registration — a push
> is written in the language of the device it lands on, which is not always the language of
> the account. What somebody is told about, and on which channel, is theirs to choose at
> `/v1/me/notification-preferences`; the bell is at `/v1/me/notifications`.

## 6. Everything else

With a token in hand, the rest of the app is the API reference. The parts that are
ours alone:

* **Messaging** — `messages:read` and `messages:write`. Threads on the buyer's side by
  default, and the shop's with `filter[side]=seller` (the same person is the buyer in one
  and the seller in the next, one side per list), starting one
  about a watch, replying, attachments, and `meta.unread` for the badge.

  Fetching a thread does **not** mark it read: you fetch one when a notification
  arrives or a list refreshes, and only you know when it was actually on screen.
  Say so with `PATCH /v1/conversations/{id}` and `{"unread": false}`.
* **Files** — `uploads:write`. `POST /v1/uploads` takes a `multipart/form-data`
  body with one `file` part (or a JSON:API document with a `sourceUrl` to fetch it
  from) and answers with a `handle`. Name that handle where the file belongs:
  `attachmentHandles` on a message, `imageHandles` on a listing — which is how a
  seller adds a watch with photographs from their phone — or `logoHandle` and
  `coverHandle` on a shop. Images and video up to 10 MB, up to 8 images on a listing.
* **The catalogue** — `catalogue:read`. `GET /v1/watches` is what is for sale from
  every seller, the same set the website shows. A seller's own stock, including
  what is not for sale, is under `/v1/sellers/{id}/watches`.

Ask for the scopes you need in the `scope` parameter when you sign in:

```
account:read account:write account-security:write checkout:write catalogue:read
content:read messages:read messages:write uploads:write
```

A scope the app was not registered for is refused at the token endpoint with
`invalid_scope`, never silently dropped.

Every list answers 25 by default and 50 at most, and `links.next` is the way on.
The lists that move while you walk them — orders, conversations, messages,
notifications — are cursors (`page[limit]`, then follow `links.next`), which do not
skip or repeat rows when something is inserted while you are walking. The short ones —
devices, addresses, favourites, saved searches, blocks, quick replies, invoices, the
shop directory, pages and articles — are numbered pages (`page[number]`,
`page[size]`). Listings, in the catalogue and under a seller, take either: a cursor
by default, numbered pages when you send `page[number]`, which is what a ranked
search wants. A list asked without `page[...]` answers its first page, not all of it.
The few lists that are never paged — the vocabularies, the help centre, related
listings and articles, sign-ins — are listed in
[Pagination](https://docs.wristler.eu/concepts/pagination.md#lists-that-are-not-paged).

## 7. Errors

Outside `/oauth/token`, everything is a JSON:API error document:

```json theme={null}
{ "jsonapi": { "version": "1.0" },
  "errors": [{ "status": "422", "title": "Unprocessable Entity",
               "detail": "The platform field is required.",
               "source": { "pointer": "/data/attributes/platform" } }]}
```

`source.pointer` names the field, so validation errors can be shown against the
right input.

| | |
| - | - |
| `401` | No token, or an expired one. Refresh, then retry once. |
| `403` | Refused. With `code: "missing_scope"` the token lacks the scope (`meta.scopes` names it); with `code: "sign_in_required"` it is a guest token, show sign-in. Do not retry. |
| `404` | Also the answer for something that exists but is not yours. |
| `422` | Validation. `source.pointer` says where. |
| `426` | Update required, see section 2. |
| `429` | Rate limited. Honour `Retry-After`. |

***

## Checklist

* [ ] `X-Wristler-App` on every request
* [ ] A blocking update screen on `code: app_update_required`
* [ ] All five outcomes of the password grant: tokens, `mfa_required`,
  `invalid_otp` (retry the code), `account_blocked` (say so, link to contact)
  and `invalid_grant` (start again)
* [ ] A fresh nonce per social sign-in
* [ ] A guest token for browsing before sign-in, replaced — not kept — once signed in
* [ ] One message for every `invalid_grant`, never "unknown email address"
* [ ] Refresh on the clock, not on a 401; sign out on `invalid_grant`
* [ ] Device registered on sign-in and on every push-token change
* [ ] The password is never stored, and never carried into the MFA step


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