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

# The account

> The person behind the token, and the address book a checkout fills itself from.

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

```json theme={null}
POST /v1/users
{ "data": { "type": "users", "attributes": {
  "clientId": "the app's client id",
  "email": "jeffrey@example.test",
  "password": "the-one-they-chose",
  "name": "Jeffrey",
  "locale": "nl"
}}}
```

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

```json theme={null}
{ "errors": [{ "status": "422", "code": "implausible_email",
  "detail": "Did you mean jan@gmail.com?",
  "source": { "pointer": "/data/attributes/email" },
  "meta": { "suggestion": "jan@gmail.com", "domain": "gnail.com" } }] }
```

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.

> [!NOTE]
>
> 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**

```json theme={null}
POST /v1/password-resets
{ "data": { "type": "password-resets", "attributes": {
  "clientId": "…", "email": "jeffrey@example.test"
}}}
```

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

```json theme={null}
PATCH /v1/password-resets/{token}
{ "data": { "type": "password-resets", "attributes": {
  "clientId": "…", "email": "jeffrey@example.test", "password": "the-one-they-chose-now"
}}}
```

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

```json theme={null}
PATCH /v1/me
{ "data": { "type": "users", "attributes": { "locale": "nl", "phone": "+31612345678" } } }
```

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

> [!NOTE]
>
> **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

```json theme={null}
{ "data": { "type": "users", "attributes": {
  "currentPassword": "the-one-they-typed",
  "newPassword": "the-one-they-chose"
}}}
```

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.

> [!WARNING]
>
> 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**

```
POST /v1/me/two-factor-setups
```

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

```json theme={null}
POST /v1/me/two-factor
{ "data": { "type": "two-factor", "attributes": {
  "secret": "JBSWY3DPEHPK3PXP",
  "otp": "123456",
  "currentPassword": "the-one-they-typed"
}}}
```

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.

> [!NOTE]
>
> **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:

```json theme={null}
POST /v1/me/identities
{ "data": { "type": "identities", "attributes": {
  "provider": "apple", "idToken": "eyJ…", "nonce": "7c1f9e2b4d", "authorizationCode": "c6a1…"
}}}
```

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:

```
DELETE /v1/me/sign-ins/{id}     one session elsewhere: a browser, or the app on another phone
DELETE /v1/me/tokens/others     everywhere else; this app stays signed in
DELETE /v1/me/tokens/current    here
DELETE /v1/me/tokens            everywhere, this app included
```

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

```
GET    /v1/me/addresses          filter[kind]=shipping|billing
POST   /v1/me/addresses
GET    /v1/me/addresses/{id}
PATCH  /v1/me/addresses/{id}
DELETE /v1/me/addresses/{id}
```

**kind** `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.

**country** `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).

**default** `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.

> [!NOTE]
>
> 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

```
GET /v1/me/orders                 newest first
GET /v1/me/orders/{id}            include=items.watch,addresses,seller
GET /v1/me/orders/{id}/receipt    a PDF
```

The other end of a seller's book of work: the same orders, read by the person who
placed them.

> [!NOTE]
>
> 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:

| | |
| - | - |
| `paid` | The money has arrived, or is settling -- or `status` is one that only a paid order reaches (`payment_received`, `in_transit`, `awaiting_delivery`, `delivered`, `completed`), which covers a bank transfer confirmed by hand. Derived, so the app never has to read Stripe's own statuses -- where `processing` means "the bank is thinking about it", which is not what our `processing` means |
| `paidAt` | When the payment arrived. `null` whenever `paid` is `false` -- including an order that passed a manual review and was then never paid |
| `reservationLapsed` | Nobody paid and the 48 hours have run out. `reservedUntil` is `null`, `paymentMethods` is `null`, and paying answers `422 reservation_expired`. A scheduled job cancels it shortly afterwards, at which point `status` says `cancelled` by itself |

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" }`:

| `event` | |
| - | - |
| `placed` | The order was made |
| `awaiting_payment` | A bank transfer is expected |
| `paid` | The money arrived |
| `shipped` | The shop handed it to the courier (a tracking number) |
| `delivered` | It arrived |
| `completed` | The sale is closed |
| `paid_out` | The shop was paid. **Only in the shop's own timeline** (`/v1/sellers/{id}/orders`) |
| `cancelled` | Cancelled: a failed payment, or by hand |
| `expired` | Cancelled because nobody paid within the 48 hours — instead of `cancelled`, so it can be told apart after the fact. `reservationLapsed` cannot: once the payment is closed it reads `false` again |
| `refunded` | The money went back |

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.

> [!TIP]
>
> 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](https://docs.wristler.eu/app/messaging.md#writing-about-an-order).

## Favorites

```
GET    /v1/me/favorites?include=watch.images,watch.watchModel
POST   /v1/me/favorites            { "relationships": { "watch": … } }
DELETE /v1/me/favorites/{id}
```

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:

```http theme={null}
GET /v1/watches/91827?include=favorite
```

```json theme={null}
{ "relationships": { "favorite": { "data": { "type": "favorites", "id": "5521" } } } }
```

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

| `availability` | Meaning | Draw it |
| - | - | - |
| `available` | In the catalogue, and can be bought. | As normal |
| `reserved` | In the catalogue, while another buyer completes their payment. | As normal, with a "Reserved" badge; the page still opens |
| `sold` | Sold. | Greyed, not tappable, "Sold" |
| `unavailable` | Not in the catalogue: taken off sale, back in review, or its shop stopped. | Greyed, not tappable |
| `removed` | The listing was deleted. | Greyed, not tappable |

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.

```json theme={null}
POST /v1/me/saved-searches
{ "data": { "type": "saved-searches", "attributes": {
  "name": "Blue Submariner",
  "words": "submariner",
  "filters": { "brand": ["Rolex"], "attributes.dial": ["BLUE"],
               "price": { "min": 500000, "max": 1500000 } },
  "interval": "instant"
}}}
```

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

| `interval` | Mail | Checked |
| - | - | - |
| `instant` | As soon as something matches | Every 15 minutes |
| `daily`, `weekly` | Gathered up | Daily at 12:00, Thursdays at 12:00 |
| `none` | No mail | With the instant ones when `push` is on; not at all when it is off |

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:

```http theme={null}
GET /v1/me/saved-searches/preview?filter[q]=submariner&filter[brand][]=Rolex
```

```json theme={null}
{ "meta": {
  "suggestedName": "Rolex Submariner", "labels": ["Rolex Submariner"],
  "matchingWatches": 64, "reachedLimit": false,
  "expectedEmails": { "instant": 51, "daily": 24, "weekly": 5 },
  "broad": true, "threshold": 50, "duplicate": null
}}
```

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:

```json theme={null}
POST /v1/me/blocks
{ "data": { "type": "blocks", "relationships": {
  "seller": { "data": { "type": "sellers", "id": "42" } } }}}
```

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](https://docs.wristler.eu/app/messaging.md#reporting-and-blocking).

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

```json theme={null}
POST /v1/me/deletion        →  202, meta.sentTo, meta.expiresInMinutes, meta.reasons

DELETE /v1/me
{ "data": { "type": "users", "attributes": {
  "code": "284193",
  "reason": "other",
  "reasonNote": "I only wanted to look around."
} } }
```

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

| `reason` | Label |
| - | - |
| `no_longer_needed` | I no longer need an account |
| `bought_elsewhere` | I bought a watch elsewhere |
| `too_many_emails` | I get too many emails or notifications |
| `privacy` | Privacy concerns |
| `bad_experience` | I had a bad experience |
| `other` | Other — shows a text field; send it as `reasonNote` (at most 1000 characters, not required) |

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:

| | |
| - | - |
| `409 account_runs_a_business` | This account runs a shop. Show a "contact us" line; who takes the business over is a conversation. |
| `409 account_has_open_orders` | An order has not finished. `meta.orders` names the order numbers in the way, so you can link to them. Somebody is waiting to send a watch, or to be paid. |
| `429 too_many_attempts` | Five codes an hour. `Retry-After` and `meta.retryAfter` say how long. |
| `422 deletion_code_invalid` | Wrong, missing, or expired. Five wrong guesses throw the code away — ask for a new one. |
| `422 deletion_reason_invalid` | `reason` is not on the list (`meta.allowed` has the list). Checked before the code, so the code is not spent. |
| `422 deletion_reason_note_invalid` | `reasonNote` is longer than 1000 characters (`meta.max`). The code is not spent. |

> [!WARNING]
>
> **`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.


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