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
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:
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
2
Set the new one
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.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.
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
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.
The second factor
1
Ask for a secret
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
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.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.
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:
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:
POST /v1/password-resetswith the relay address. Apple forwards the mail to the person’s real inbox, as it does every mail we send them.PATCH /v1/password-resets/{token}sets a password they know. An account made with Apple has one nobody knows.PATCH /v1/mewith the newemailand that password ascurrentPassword.
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:
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
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.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).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.
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
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.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.
Favorites
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 answers200
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.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
409and codesaved_search_exists: the same filters in any order and the same words in any case.meta.savedSearchis the id of the one already saved,meta.nameandmeta.intervalsay which it is, anddetailsays it in the reader’s language. Open that one instead. APATCHthat 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
422and codebroad_searchuntil the person confirms.metagivesmatchingWatches,reachedLimit,expectedEmails(instant,daily,weekly: how many mails last month would have sent) andthreshold: what the website’s “This is a broad search” box shows. Send the same request again with"acceptsManyEmails": trueto 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.
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: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.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:
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.