Skip to main content
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. 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

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:
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

There are three outcomes and the app must handle all three. Success — an ordinary OAuth token response:
Two-factor required — 401, with error: "mfa_required":
Ask for the six-digit code and post again, with the challenge instead of the credentials. Do not keep the password around for this:
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":
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:
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: 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:
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: 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:
  • 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: 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.
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
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:
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.

7. Errors

Outside /oauth/token, everything is a JSON:API error document:
source.pointer names the field, so validation errors can be shown against the right input.

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