Skip to main content
Scopes account:read and account:write, with account-security:write for the second factor, Apple and Google, the sign-ins and signing out — except the three that happen before there is a token. GET /v1/me itself asks no scope.

Signing somebody up

The person is signed in when it answers: meta.session holds the same tokens the token endpoint would have handed over, issued in-process by urn:wristler:native-login — so there is still one place deciding what a session is worth. Name the scopes in scope, as at the token endpoint. meta.session is missing when this client cannot sign anybody in, and when the token endpoint would refuse the scopes asked for (one the client is not registered for is invalid_scope there); the account is made either way, so sign in at the token endpoint.

A mistyped address

The website’s typo check runs here too: an address a typo away from a popular mail domain (jan@gnail.com, hotmai.com) or on a domain that receives no mail is refused with 422, code implausible_email, pointer /data/attributes/email:
Show detail under the field with two ways on: take meta.suggestion (it is null when there is no guess, only a domain that receives no mail), or “use anyway” — the same request again with "emailConfirmed": "jan@gnail.com", the address as typed. It is the address, not a flag, as on the website: change the address and it is asked again. PATCH /v1/me asks the same of a new address, never of the one on file. The check is asked only once everything else about the address is fine, so a taken address is still reported as taken. The account works before the address is verified: verification gates what somebody may do, not whether they may sign in. /v1/me carries emailVerified, and POST /v1/me/email-verifications sends the mail again.
Signing up and the two password-reset requests below have no token to read a scope from, so the app names itself: send clientId, the same one it signs in with. Any other kind of client, or a revoked one, is refused with 403 and code client_may_not.

A forgotten password

1

Ask for the mail

The answer is the same whether or not that address has an account. Show it as it comes: telling the two apart would make this a way to find out who is a member.
2

Set the new one

The token comes out of the mail; the address has to come with it, because a token on its own does not say who it is for. Expired, used, or the wrong address: 422 with code: reset_failed.Every session ends here — the app’s tokens on every device, every browser and every “remember me” — because somebody who had to reset a password may have lost control of the account.
The link in the mail is the website’s reset page in the person’s language — https://wristler.eu/nl/wachtwoord-resetten/{token}?email=…, /password-reset/{token} in English — with the token as the last path segment and the address in email: all that PATCH needs. Today it opens the website, and the person finishes there: the app-link files (apple-app-site-association, assetlinks.json) list listings, filter pages and shops, not this page, so the app is not offered it. An app that is handed the link some other way — pasted, or from the share sheet — can take the token and address out of it and finish here.

Editing the person

name, lastName, email, phone, locale. Only what the body names is changed — a PATCH carrying an email does not reset a phone number to whatever the app last happened to know. A new email needs currentPassword beside it, as a new password does: the address is where the next reset mail goes. Sending back the address already on file (in any case) is not a change and needs nothing. A new address is unverified until its mail is confirmed; the old address is told about the change.

Changing the language

locale takes one of en, nl, de, es, fr, it, pt, fi, pl — the same nine Accept-Language accepts. Anything else is 422, not ignored.
It is the language we write to them in, not only the one they read the app in. Every mail we send follows it: the verification mail, a password reset, a saved search that matched — including the links inside them, which point at that language of the website. So it is worth setting once from the phone’s language at sign-up, and again whenever somebody changes it here.
A new account takes it from the header on the exchange. Apple and Google tell us nothing about language, so Accept-Language on POST /oauth/token is the only thing that says — send it there as well as on everything else, or a Dutch phone gets an English verification mail before anybody has opened the account screen. A language we do not write in falls back to English rather than being refused. It does not decide what this API answers in. There is no session on the API host, so Accept-Language on each request is still the only thing that says — send the header from the stored value rather than expecting the account to be consulted.

Changing a password

The current one is required beside the new one. A token lifted off a device must not be enough to lock somebody out of their own account. Wrong passwords are counted, and too many answer 429. A new password or a new address signs the account out everywhere else — every other app token and every browser — and leaves the token asking signed in.
Neither is ever returned, at any scope. Nor is a second factor’s secret or its recovery code — those are handed over once, at the moment they are made.

The second factor

1

Ask for a secret

Answers with secret and otpauthUri (an otpauth:// URI to draw a QR code from). Nothing is stored on the account yet; the secret is held for 30 minutes, and only that secret can be turned on.
2

Turn it on

The code proves the secret reached the phone; the password stops a token lifted off a device from changing what protects the account.409 two_factor_already_enabled when the account has one already — turn that one off first. 422 two_factor_setup_expired when the secret is not the one handed out (or the 30 minutes ran out): start the setup again. A wrong code or password is a 422 pointing at otp or currentPassword.The answer carries the recovery code, once. Show it, and say it will not be shown again — there is no endpoint that reads it back, because a recovery code you can fetch with the token you already hold is not a recovery code.
Turning it off is DELETE /v1/me/two-factor with a current code (otp) and the password (currentPassword), and POST /v1/me/recovery-codes replaces the code with a new one on the same terms. Turning it on or off signs the account out everywhere else, and the owner is told by mail and push.
The recovery code works wherever a code is asked for — here and at sign-in. That is the whole point of having one: the phone is what is gone. It is burned the moment it is used, so hand out a new one straight after.
Wrong codes are counted the way they are at sign-in, so guessing is no easier from the app than from the website.

Apple and Google

identities on /v1/me says which of the two the account can be signed in with: ["apple"], ["apple", "google"], []. Signing in with one already links it when the provider’s address is verified and matches; to link one that does not match — an Apple ID that hides the address, a Google account on another address — send what the native sheet gave you:
For Apple, send the sheet’s authorizationCode as well, as at sign-in. We trade it for the refresh token that deleting the account revokes, which Apple requires. Without it the link works, but there is nothing to revoke. Unlinking Apple revokes it too. Scope account-security:write. 201 with meta.identities, 200 if it was already linked. 409 identity_in_use when that Apple ID or Google account belongs to another Wristler account, 409 identity_already_linked when a different one of the same provider is linked here, 422 identity_not_verified when the token does not check out, 422 unknown_provider for anything but apple or google. DELETE /v1/me/identities/apple unlinks: 204, or 404 when it is not linked. Refused with 409 last_way_to_sign_in only when it is the last identity and the account’s address is an Apple private relay one — nothing else could reach that account. Any other address can always set a password through the reset mail. The way out for such an account is to put its own address on it first:
  1. POST /v1/password-resets with the relay address. Apple forwards the mail to the person’s real inbox, as it does every mail we send them.
  2. PATCH /v1/password-resets/{token} sets a password they know. An account made with Apple has one nobody knows.
  3. PATCH /v1/me with the new email and that password as currentPassword.
After that, Apple can be unlinked. Both ways the owner is mailed, and told in the bell and by push (sign_in_method_changed): a linked identity is a way in without the password. The website’s security page (My account → Security) links and unlinks the same two, through the same rules.

Recent sign-ins

GET /v1/me/sign-ins (scope account-security:write) is every session of the last 90 days that is still signed in, newest first: client (app or web), device in the reader’s language (“Wristler app on iOS”, “Safari on macOS”), appVersion, country and countryName (from Cloudflare; the list gives the country and nothing finer), signedInAt, and current on the row that is the token asking, which stays true across refreshes. Send X-Wristler-App on the token request, as on every request, or the app’s sign-ins cannot say which platform they were. Signing out, from the same screen:
All four ask account-security:write, answer 204 and are safe to call twice. A sign-in ended by id, by current, by others or by “everywhere” leaves the list: the app’s token and its refresh token are revoked, a browser is signed out on its next click. An id that is not one of the account’s sign-ins answers 404. DELETE /v1/me/sign-ins/{id} on the row marked current is refused with 422 and code current_sign_in — signing out here is /v1/me/tokens/current, after which the app forgets its tokens. The website’s security page does the same through the same code. A sign-in on a device the account was not used on before — another platform, or another browser or operating system; an update is not a new device — is mailed to the owner, and lands in the bell and as a push: new_sign_in. The account’s first sign-in is not. The website’s security page shows the same list.

The address book

string
required
shipping or billing. Not type — JSON:API reserves that member for the resource itself, and a resource carrying an attribute by that name is refused by the specification rather than by us.
string
required
Two letters, any ISO 3166-1 country: an address book holds addresses in countries we do not ship to as well. Anything that is not a country code is 422. A checkout refuses a country it does not ship to with 422 country_not_shipped_to (see GET /v1/shipping-countries for the list).
boolean
The one a checkout reaches for. Setting it takes it off whichever address of that kind had it: two defaults is not a state anything can act on.
Only ever your own. Somebody else’s answers 404, not 403 — an id that says “exists, but not yours” is an id that can be walked to find out who lives where.
These are not the addresses on an order. Those are copies taken at the moment of sale — an order has to keep saying where it went even after somebody moves house — and they live under the order, read-only. They carry kind too, but in capitals: SHIPPING or BILLING.

What they bought

The other end of a seller’s book of work: the same orders, read by the person who placed them.
Five fields come back null here, and on purpose: applicationFee (what we charge the seller), payoutAmount (what they are paid) and paidOutAt (when), and buyerEmail and buyerPhone, which are for the shop. None of it is a buyer’s business, and a number somebody has to interpret is worse than no number. notes and coupon are not among them: the buyer wrote both at the checkout, so both sides read them.
An order you were not in answers 404, not 403 — an order number is a year and a counter, so “exists but not yours” would be a way to count the platform’s orders. The receipt is the same PDF the website hands out, built by the same code, so the copy in the app is the copy in their mail. receiptUrl on the order is its address, and null until the order is paid.

Telling a live order from an abandoned one

status is not enough on its own: processing is the column’s default, so it means both “paid, we are handling it” and “somebody started a checkout and walked away”. Two booleans say which, and paidAt says when: Neither boolean is folded into status, deliberately: status is the stored value filter[status] sorts and counts by, and a computed one mixed in would make the filter disagree with the field.

The timeline

timeline is every step the order went through, in order, as { "event", "at" }: Every step is written as it happens from now on, so at is that moment. For an order from before, the steps are worked out from the order itself: placed, paid, delivered and paid_out have their dates, and a step it only knows happened — shipped, completed, cancelled, refunded — comes back with "at": null. An older order that expired shows as cancelled: nothing recorded the difference then. Draw it without a date rather than guess one. shippedAt and cancelledAt (which an expiry fills too) are the same moments as attributes, null when the step has not happened or has no date. The website’s order pages, the buyer’s and the shop’s, show the same timeline.
Talking about an order is messaging, through the order itself: POST /v1/me/orders/{id}/conversation writes in the thread about the watch that was bought — the one already there, or a new one — and works after the listing is deleted. See Writing about an order.

Favorites

A listing carries favorited, which is what a heart on a card reads — so a list of search results already knows which ones are filled in, without a second call.

Turning a heart off

Removing a favorite needs its id, and a card drawn from a listing does not have one. So a listing carries the viewer’s own favorite as a relationship:
null when they have not put it aside, and never anybody else’s — it is the viewer’s own place on the list, not the listing’s list of admirers. Ask for it where a heart has to toggle; favorited alone is cheaper where it only has to be drawn.

Both ways are idempotent

Putting the same watch aside twice is putting it aside: the second answers 200 with the one that is already there. A client that lost track is not a client asking for a duplicate, and there is nothing to update about a favorite anyway — it is added or it is gone. An Idempotency-Key works here as it does on every write, and is worth sending for an optimistic toggle that may retry.

When the watch goes

A favorite stays on the list whatever becomes of its watch — somebody who saved it wants to see that it sold or went, not find it gone. availability says which: The included watch carries its photo, name and price in every case, a removed one too (with webUrl: null). Only available and reserved have a page: GET /v1/watches/{id} does not serve a sold, deleted or withdrawn listing to somebody who is not its shop. filter[available]=true lists what is still in the catalogue (availability available or reserved); false lists the rest. The website’s favourites page shows the same states and has the same “Only available” switch. Somebody else’s draft, or a listing still waiting on review, cannot be put aside: it answers 404, as if it were not there.

Saved searches

The other half of what an app is better at than a website: being told the moment something matches.
The same words a search is made of: words is filter[q], and filters is the facets, keyed exactly as they are sent to /v1/watches; price is { min, max } in cents, and one end is enough ({ "min": 500000 } is “from €5,000”). What we store underneath is the expression the matcher hands the index — that stays ours, because nobody should have to write a Meilisearch filter to be told about a new Submariner. Reading one back gives the filters as they went in. interval is the mail, and push (boolean, true unless sent otherwise) the phone, per search: So a search that only pushes is interval: none, push: true; one that only mails is push: false. Push still needs the saved_searches category on in the notification preferences, and the phone’s permission — the API cannot see the latter, so warn about it in the app. The website edits name and interval only and leaves push as it is. Leave name out and it is named after the date, as on the website. Each one carries url, where the same search opens in a browser, and newResults: the website’s “N new results” badge — watches listed since the search last notified, among its 100 best matches, so at most 100 (show “99+” above 99, as the website does); null when the search service could not be asked. Saving checks what the website checks, with the same code:
  • The same search twice is refused with 409 and code saved_search_exists: the same filters in any order and the same words in any case. meta.savedSearch is the id of the one already saved, meta.name and meta.interval say which it is, and detail says it in the reader’s language. Open that one instead. A PATCH that would turn a search into another one the person has is refused the same way.
  • A broad search — one that matched 50 watches or more last month — is refused with 422 and code broad_search until the person confirms. meta gives matchingWatches, reachedLimit, expectedEmails (instant, daily, weekly: how many mails last month would have sent) and threshold: what the website’s “This is a broad search” box shows. Send the same request again with "acceptsManyEmails": true to save it anyway. Not asked of a search that sends nothing (interval: none, push: false), and skipped when the estimate cannot be made — the estimate is advice, never the reason a search cannot be saved.
Before saving, ask what the website’s modal shows, so neither check above comes as a surprise:
Send the search as you would save it (filter[q] is words, every other filter[...] one of the filters). Prefill the name with suggestedName; show the expected mails for the chosen interval; ask for the confirmation up front when broad is true (and send acceptsManyEmails with the save); and when duplicate is set, offer to open that search (duplicate.savedSearch, with duplicate.detail to show). The numbers are null when the search service could not be asked — saving still works then.

Blocked businesses

Somebody who wants nothing more from a shop blocks it:
Against the shop, not against whoever wrote — a business is staffed by several accounts. Blocking twice answers 200 with the block that is already there, and 422 cannot_block_your_own_business covers a shop the caller works at. GET /v1/me/blocks?include=seller is the screen, and DELETE /v1/me/blocks/{id} undoes it. To unblock from inside a conversation, GET /v1/me/blocks?filter[seller]=42 answers a list of one rather than making the app page the whole thing for an id. A seller carries blocked as well, so a listing can say so before anybody types a first message. What a block does to messaging — and the 403 conversation_blocked that enforces it — is in Messaging.

Deleting the account

App Store guideline 5.1.1(v): an account made in the app has to be closable in the app. Two requests, because a token on its own is not enough — a phone taken from somebody is a phone with a signed-in app on it, and the mailbox is the one thing whoever took it does not have.
Why they are leaving is optional, and asked on the screen where the code is typed. Draw the picker from meta.reasons of the first request — { id, label, takesNote }, labelled in the request’s language — and send the chosen id as reason. The values today: A reasonNote with any other reason is dropped, not refused. Leave both out and nothing changes. Support gets a mail about every deletion, with the reason when there is one. Ask for the code first and show the refusals it answers with — they are checked again at the deletion, but finding out after somebody has typed a code is the wrong moment:
DELETE /v1/me signs the app out. Every token on every device goes, the push registrations with them, so that answer is the last one this token will ever get. Read meta.purgesAt out of it before clearing your state: it is the date there is no way back from, and the screen after deletion should say it.
The account is not gone at that moment. For thirty days it is marked, and signing in cancels the deletion — no support ticket, no link that expires. Say so, both on the confirmation screen and after: somebody who deleted their account in anger on Friday should know that signing in on Monday is all it takes. The same mail says it too. What happens after those thirty days, and what to tell somebody who asks: the person is anonymised and the account is closed for good. Their orders, payments and invoices stay in the bookkeeping without their name, because those are a seller’s records and ours as much as theirs and the law requires us to keep them. Their messages stay for the same reason — removing them would empty out the other side of a conversation. Everything that is only theirs goes: the address book, favourites, saved searches, notifications, devices, blocks.