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
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:
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:
401, with error: "mfa_required":
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.401, with error: "invalid_otp":
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:
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:- Open
https://appleid.apple.com/auth/authorizewith our Services ID asclient_id,response_type=code id_token,response_mode=form_post,scope=name email, yournonceand astate, andredirect_uri=https://api.wristler.eu/oauth/apple/return. - We answer Apple’s post with a
303to the app’s App Link, carryinghandoffand yourstate— orerrorandstatewhen the person cancelled. The ID token itself never travels in a URL. Checkstate. - Exchange the code like any other social sign-in, with
handoffin place ofid_token, and the samenonce:
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
403withcode: "sign_in_required", never401. A guest token is a good token; the person just has to sign in first. So a401keeps 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,
429witherror: "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.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
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. Sendlocalewith 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:readandmessages:write. Threads on the buyer’s side by default, and the shop’s withfilter[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, andmeta.unreadfor 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 withPATCH /v1/conversations/{id}and{"unread": false}. -
Files —
uploads:write.POST /v1/uploadstakes amultipart/form-databody with onefilepart (or a JSON:API document with asourceUrlto fetch it from) and answers with ahandle. Name that handle where the file belongs:attachmentHandleson a message,imageHandleson a listing — which is how a seller adds a watch with photographs from their phone — orlogoHandleandcoverHandleon a shop. Images and video up to 10 MB, up to 8 images on a listing. -
The catalogue —
catalogue:read.GET /v1/watchesis 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.
scope parameter when you sign in:
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-Appon 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) andinvalid_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