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

# Messaging

> A thread between one buyer and one seller about one watch — from whichever end you are on.

Scopes `messages:read` and `messages:write`.

| | |
| - | - |
| `GET /v1/conversations` | Your threads as a buyer; `filter[side]=seller` for your shops' |
| `POST /v1/conversations` | Start one about a watch, or pick up the open one |
| `GET /v1/conversations/{id}` | One thread |
| `PATCH /v1/conversations/{id}` | Mark read, or archive |
| `GET /v1/conversations/{id}/messages` | The messages in it |
| `POST /v1/conversations/{id}/messages` | Send one |
| `GET /v1/conversations/{id}/messages/{messageId}` | One message |
| `PATCH /v1/conversations/{id}/messages/{messageId}` | Answer an offer |

## The app is the buyer's side

`/v1/conversations` returns **the threads this person bought in** — for every account,
including the people who run shops. A shop's own correspondence belongs to the seller
environment, which somebody has to switch into and which does not exist yet; an
administrator gets no platform-wide view either.

`filter[side]=seller` is the opt-in for the shops they belong to, and is what the seller
environment will send. `meta.unread` follows whichever side was asked for, so the badge and
the list under it cannot disagree.

The same rule governs notifications: a message or an offer reaches the **buyer's** phone and
bell. The people at a shop are mailed, as they always have been, and pushed — never the bell —
with `side: "seller"` and the shop's id in the push, so the app switches into that shop before
it opens the thread ([a shop's news →](https://docs.wristler.eu/app/devices.md#a-shops-news)).

**side** `string`

`buyer` or `seller` — which end of this thread you are on.

**unread** `boolean`

Answered from that end. In the database these are two columns; a client never has
to know that.

**archived** `boolean`

Also per side. The other party archiving a thread does not file yours away.

The two filters work the same way: `filter[unread]=true`, `filter[archived]=false`.

Two more work on either side. `filter[hasOpenOffer]=true` is the "offers" segment: threads
with an offer still waiting — a buyer's offer nobody has answered or withdrawn, or a shop's
proposal that has not run out. `filter[q]` is the search field above the list: every word has
to appear in the watch's name or reference, or in the name at the other end — the shop on the
buyer's side, the buyer on the shop's.

On the shop's side there is a third, the website's "Unanswered":
`filter[side]=seller&filter[unanswered]=true` — not archived, the last message not yours (a
thread with no messages yet, or one a colleague answered last, is waiting too), and not one
marked `noReplyNeeded` unless the buyer wrote since. The same rule as the dashboard's list,
so both show the same threads. On the buyer's side it is `400 seller_side_filter`.

**agreedPrice** `integer`

What the website's chat header shows instead of the list price: the newest proposal in this
thread that still stands, in cents. `null` when none does.

**order** `object`

The order this thread led to, once the watch is sold and this buyer paid for it —
`{ "id": "7741", "number": "202607741" }`. Open it at `/v1/me/orders/{id}` on the buyer's
side, `/v1/sellers/{sellerId}/orders/{id}` on the shop's; `number` is what people quote.
`null` otherwise. The website's header links the order in the same case.

**buyer** `object`

Who the shop is talking to, on the shop's side only: `name`, `initials`, and
`estimatedCountry` (ISO 3166-1 alpha-2, where they seemed to be when they first wrote;
`null` when unknown). `null` on the buyer's side, and once the buyer deleted their account.

## Drawing the list

A row shows a photo, a name, one line of text and a dot. All four are in the list itself,
so drawing it is one request.

**lastMessage** `object`

What the row says. `id` is the message's id; `excerpt` is plain text, already stripped and collapsed to one line
and cut on a word boundary; `createdAt`; `from` (`buyer`, `seller` or `system`); `mine`;
`removed` when a moderator took the words out; and `priceProposal` with `amount`,
`currency`, `from`, `status`, `open`, `payable` and `generatedBody` when the last message
is an offer.

`generatedBody` says whether the wording is ours, exactly as it does on the message itself.
When it is true, draw your own card and ignore the excerpt -- our sentence under a card that
already says the amount reads as the buyer saying it twice.

`null` for a thread with nobody having said anything yet, which the website can produce.

Do **not** use `include=messages` for this. That sends every message of every thread: two
threads of fourteen and fifteen messages were two thirds of the answer, it grows with every
reply, and the list is polled. `include=messages` belongs on one thread, once it is open.

For the photo and the name, `include=watch.cover,watch.watchModel`. `cover` is one upload
with the full set of variants — the same photo the detail page opens on — rather than the
whole gallery.

```
GET /v1/conversations?include=watch.cover,watch.watchModel&page[limit]=25
```

**Staff at two shops.** `filter[side]=seller` gives every shop the person belongs to, which is right
for a seller with one and wrong for somebody who has just switched into one of two. Add
`filter[seller]=42`, and `meta.unread` narrows with the list. It only ever narrows: an id belonging
to a business they do not work at answers an empty list rather than somebody else's threads.

## Reading is something you say, not something we guess

Fetching a thread does **not** mark it read. You fetch one when a notification
arrives, when a list refreshes, when a screen is rebuilt — only you know when it
was actually in front of somebody.

```json theme={null}
PATCH /v1/conversations/3390
{ "data": { "type": "conversations", "id": "3390", "attributes": { "unread": false } } }
```

`unread: true` puts it back on the pile, and `archived` works the same way in the
same call. Send only what you are changing: a `PATCH` carrying `archived` alone
leaves the read mark exactly as it was.

Neither counts as activity, so neither moves the thread to the top of the list. A
reply does.

> [!TIP]
>
> `meta.unread` on the list is how many threads are waiting on you, on the side the
> list asked for (the buyer's unless `filter[side]=seller`, narrowed by `filter[seller]`)
> and across the whole list rather than the page. That is your badge — paging
> a list to count it is the wrong shape for a number that has to be right on every
> screen.

## Starting one

```json theme={null}
POST /v1/conversations
{ "data": { "type": "conversations",
  "attributes": { "message": "Is this one still available?" },
  "relationships": { "watch": { "data": { "type": "watches", "id": "91827" } } }
}}
```

`message` is 6 to 5,000 characters, as on the website. The seller is not named: a watch
belongs to one business, so naming them would be
a second way to say the same thing and a way to disagree.

**It is an upsert.** There is one thread per buyer, seller and watch, so asking
twice does not make a second one — `201` when the thread is new, `200` when one was
already open, and either way the message is added and the thread is un-archived for
both sides.

**Photos with the first message.** `attachmentHandles` goes on `POST /v1/conversations`
as on `POST .../messages` — handles from `POST /v1/uploads`, four at most — and the files
land on the opening message. Only with words of the buyer's own: beside a `priceProposal`
or `requestAvailability` it is a `422` on `/data/attributes/attachmentHandles`, and without
a `message` the `422` points at `message`. `?include=messages,messages.attachments` hands
the opening message back with its files.

Only about a watch that is for sale at a shop that can take payment — anything else is
`422 watch_not_for_sale` — and not one listed by a business you work at
(`422 cannot_message_your_own_business`), as the watch page offers a message in neither
case. A thread that already exists about a watch that has since sold is written in with
`POST /v1/conversations/{id}/messages`.

### Asking whether it is available

A watch with `availabilityOnRequest: true` is not bought straight away (`purchasable`
is `false`): the shop has to say it is there first. The website shows "Request
availability" instead of Buy, and this is that button:

```json theme={null}
POST /v1/conversations
{ "data": { "type": "conversations",
  "attributes": { "requestAvailability": true },
  "relationships": { "watch": { "data": { "type": "watches", "id": "91827" } } }
}}
```

No `message` and no `priceProposal` with it — either is a `422` (pointing at `message`, or
at `requestAvailability` for an offer):
the thread opens with the same sentence the website sends ("I would love to hear about
the availability of this watch…", signed with the buyer's name), and the shop reads it
like any message. The same upsert as above. A watch that is not on request answers
`422 availability_not_on_request`; ask a question instead.

The shop answers in the thread, usually with a proposal. That proposal is the way into
the checkout: `POST /v1/me/orders` for this watch without an `offer` is
`422 availability_on_request`, as the website's checkout only opens from the shop's link.

## What a message is

**body** `string`

What to show, already through the same purifier and placeholder substitution the
website runs on every render. `null` on a message a moderator removed — the
message stays in place, because a hole nothing explains reads as a bug — on a photo
sent without words, and on a seller's proposal, which is drawn from its figures.

**author** `string`

`buyer`, `seller` or `system`. A message with no author is ours — there is one,
the payment confirmation — and it is rendered differently.

**priceProposal** `object`

An offer, from either side, as figures. Null on an ordinary message.

`amount` in cents, `currency`, and `from` — `buyer` or `seller`, which decides what
the card says and who may answer it. `status` is one of `pending`, `accepted`,
`withdrawn` or `expired` — or `refused`, only on an offer answered before offers
stopped being refused. `payable` says the only thing a checkout cares about, and
`checkoutUrl` is set while it is payable.

`state` is what the website's card says about a **seller's** proposal — null on a
buyer's offer:

| `state` | |
| - | - |
| `open` | Payable, more than 6 hours left |
| `expiring` | Payable, 6 hours or less left |
| `expired` | The 48 hours ran out |
| `superseded` | Ran out, and a newer proposal or offer was sent in the thread since |
| `unavailable` | The watch is sold, offline or deleted, so no amount buys it |
| `paid` | An order was paid at it. Wins over everything, also after the 48 hours |

`expiresAt` is null on a buyer's offer: a question has no clock, and it stands until
it is answered, withdrawn, or **the seller sends a proposal of their own in the
thread** — then it lapses, as `status: "expired"` (`state` stays null). A seller's
proposal expires after 48 hours.

`acceptedAs` is the message id of the seller's proposal that accepting a buyer's
offer produced — that one is the payable row, and where a "pay now" button points.

`generatedBody` is true when the message beside the offer is our sentence rather
than the buyer's own words — and always on a seller's proposal. Draw your own card and
ignore the body when it is.

`shippingCosts` is what the buyer pays for shipping on top of `amount`, in cents: what
the seller set with their proposal, otherwise the listing's.

`saving` and `savingPercentage` are the website card's "You save €x (−y%)": `amount`
plus shipping against the listing's price plus shipping, in cents and as a whole
percentage. Null when nothing is saved or the listing has no public price. Show them
as they come rather than working them out — the rounding is the website's.

**translatedBody** `string`

Machine translation, made once when the message was written, into the account
language (`locale` on `/v1/me`) of the side that reads it. Only that side gets it;
for the author it is null, and an offer has none. Show it when there is one and `body` otherwise. Treat it
as a behaviour we may change rather than as a promise.

**translationSourceLocale** `string`

The language the message was written in, for "translated from German". Null on
older messages, and when there is no translation for you.

**translationLocale** `string`

The language it was translated into. No translation is kept when the message was
already in that language: `translatedBody` is then null and `body` is what you show.

**translationPending** `boolean`

True while a translation for you is still on its way. Show the original until it
arrives; it turns false when it came, failed, or has taken too long.

## Sending one

`body` is plain text with no minimum length and at most 5,000 characters, and may be left out when
`attachmentHandles` names a file: a photo on its own is a message, as in the
website's chat. Its `body` then comes back null, and a thread list's excerpt says
"Photo" or "2 photos" in your language. Links to anywhere but Wristler are taken
out before it is stored, exactly as on the website.

Sending one is not writing a row: the thread turns around — read for you, unread
for the other side, un-archived for both — and the other side is mailed and the
message queued for translation.

**Attachments** are handles, at most 4. Send each file to
[`POST /v1/uploads`](https://docs.wristler.eu/guides/uploads) first — the bytes, or a `sourceUrl` we fetch —
and name what comes back:

```json theme={null}
{ "data": { "type": "messages", "attributes": {
  "body": "Here is a photograph of the clasp.",
  "attachmentHandles": ["/wristler-image-storage/clasp-20260923.jpeg"]
}}}
```

What comes back on the message is the `attachments` relationship, so ask for the
files themselves with `include=attachments`.

## Offers

Both sides can offer, and the two are not the same kind of thing.

**A seller's offer is binding.** Sending it *is* the agreement: it comes back
`payable: true` with a `checkoutUrl`, it stands 48 hours, and a new one replaces the
one before it.

**A buyer's offer is a request.** It comes back `pending` and `payable: false`, with
`expiresAt: null`, and it is worth nothing at a checkout until the seller accepts it.
A buyer may have one outstanding at a time.

Either way it is the same call, and the amount is what you send:

```json theme={null}
POST /v1/conversations/3390/messages
{ "data": { "type": "messages", "attributes": {
  "priceProposal": { "amount": 1300000 }
}}}
```

On a buyer's offer `body` is optional: leave it out and we write the sentence — which
comes back with `generatedBody: true`, so an app that draws its own card can ignore it —
or send the buyer's own words and they are the message. A seller's proposal carries no
words of its own, as on the website: a `body` sent with it is refused with `422` on
`/data/attributes/body` — send the words as a message of their own. Photos do not go with
an offer from either side: `attachmentHandles` beside a `priceProposal` is a `422` on
`/data/attributes/attachmentHandles`.

A **seller** may add `shippingCosts` (cents, `0` for free shipping, up to `100000`), as the
website's proposal form asks for; leave it out and the listing's shipping applies. On a
buyer's offer it is a `422` on `priceProposal.shippingCosts` — shipping is the shop's to set:

```json theme={null}
{ "priceProposal": { "amount": 1300000, "shippingCosts": 0 } }
```

**What each side is held to.** A buyer is held to what the business allows: that it
takes offers, and the floor it set (`minimumProposal` on the listing). A seller is held
to the website's proposal form and nothing else: the watch is still for sale, and the
amount is at least €750 (`75000`) — under it is an ordinary `422` validation error on
`priceProposal.amount`. The business's own floor and its offers switch do not apply to
its own proposals.

**Starting a thread with an offer** is one request too: put the same
`priceProposal` (its `amount`) on `POST /v1/conversations`. `message` is then optional —
leave it out and we write the sentence, as above. It is held to exactly the rules of a
buyer's offer in a thread, with the same refusals below — `shippingCosts` included, which
is a `422` from a buyer — and checked before anything is written, so a refused offer
leaves no thread behind.

### Answering one

```json theme={null}
PATCH /v1/conversations/3390/messages/5512
{ "data": { "type": "messages", "id": "5512", "attributes": {
  "priceProposal": { "status": "accepted" }
}}}
```

Only a buyer's offer can be answered, and only while it is `pending`. `accepted` is the
seller's; `withdrawn` is the buyer's, on their own offer. **There is no refusing**: a seller
declines in the conversation, in words or with a proposal of their own. Words leave the
offer `pending`; a proposal of their own makes it lapse, as `expired`, and the buyer may
then make another. `refused` answers `422 cannot_refuse`, and answering a lapsed offer
`409 offer_closed` with `meta.status: "expired"`.

> [!WARNING]
>
> **Accepting sends the seller's own proposal** for that amount rather than making the
> request payable. The answer carries `status: "accepted"` and
> `acceptedAs: "<message id>"`, and that message is the one with the 48 hours, the
> `checkoutUrl`, and the id `POST /v1/me/orders` takes as its `offer`. A seller who
> wants a different number does not accept — they send a proposal, which is the
> counter-offer.

How it is refused, and each says which:

| | |
| - | - |
| `409 offers_not_accepted` | A buyer's offer, and this business does not do price proposals, or this listing takes none — which is what `minimumProposal: null` means. Never on a seller's own proposal. |
| `422 below_minimum` | A buyer's offer below the floor this business set for the listing. Never on a seller's own proposal. |
| `422 watch_not_for_sale` | Nothing to offer on: sold, withdrawn, or the shop cannot take payment. |
| `409 offer_already_open` | This buyer already has one waiting. `meta.offer` says which. |
| `422 not_an_offer` | A `PATCH` on something that is not a buyer's offer. |
| `422 cannot_refuse` | A `PATCH` with `refused`. Decline in the conversation instead. |
| `409 offer_closed` | Already answered or expired. `meta.status` says which. |
| `403 not_yours_to_answer` | The wrong side is asking. |

**Before showing the button**, read the listing: `minimumProposal` is the lowest
this business allows on it, in cents — never under `75000` (€750), the least any
offer may be — and `null` when they do not take offers at all. The business itself carries `acceptsPriceProposals`.

**Opening a checkout from an offer.** `POST /v1/me/orders` takes the accepted offer's
message id as `offer`, and the amount it will charge is that message's
`priceProposal.amount` — read it back with
`GET /v1/conversations/{conversationId}/messages/{id}` rather than keeping the figure from
the screen the buyer tapped. The message is the record of what was agreed: it carries the
amount, the expiry, and `checkoutUrl`, and it is still there when the app is reopened or
the checkout is entered from a notification.

## "No reply needed"

A shop can say a thread needs no answer — "thanks!", a buyer who will think about it — with
`PATCH /v1/conversations/{id}` and `noReplyNeeded: true`. It stops the "buyers are waiting"
reminder for that thread until the buyer writes again; then it reads `false` by itself, because
that is a new wait. The buyer's side reads `null` and cannot set it (`422 only_the_shop`). The
website's seller chat has the same button, on the same column.

## Writing about an order

"Ask the seller" on an order, and the shop's "message the buyer", are not
`POST /v1/conversations`: that starts a thread about a **listing**, and after a sale the
listing is often deleted. Write through the order instead — it works with the listing gone,
exactly as on the website:

```json theme={null}
POST /v1/me/orders/7741/conversation                  (the buyer)
POST /v1/sellers/42/orders/7741/conversation          (the shop)
{ "data": { "type": "conversations", "attributes": {
  "message": "When will my watch be shipped?"
}}}
```

`messages:write`. The message goes into the thread about the order's watch — the one already
there, or a new one with `purpose: "aftercare"` — and the answer is that thread (`201` new,
`200` existing), so the app can open it. At least 6 characters, as on the website. An order
that kept no listing at all answers `422 order_has_no_listing`, and the shop writing about an
order whose buyer has deleted their account `422 buyer_account_deleted`.

## Quick replies

A shop's canned answers live at `/v1/sellers/{id}/quick-replies` — `title` for the picker,
`body` for the message box, `position` for the order — and the website's seller chat offers the
same list, kept in the dashboard's settings. Everybody at the shop shares them; at most 50 —
the 51st is `422 too_many_quick_replies`.

To reorder, `PATCH` one with the `position` you want it at — `1` is the top, past the end is
the end — and the whole list is numbered again from one, as the website's up and down buttons
do. Moving one up is its current `position` minus one. A new one goes at the end, unless the
`POST` names a `position` too.

## Reporting and blocking

Both are required before the app can ship: App Store guideline 1.2 asks that a platform
carrying what its users write lets people report it and get away from whoever wrote it.
They are separate actions and neither implies the other — somebody may report a scam
attempt and still want an answer about the watch, or block a shop they have nothing to
report about.

### What may be reported

```json theme={null}
GET /v1/report-reasons
```

Six reasons, each with a translated `label`, a `description`, and `needsNote`. Draw the
sheet from this rather than from a list in the app: a reason added here appears without a
release, and the wording arrives in the right language. `needsNote` says whether the
button stays disabled until somebody has typed something — today that is `other`, and an
app that hardcodes which one it is will be wrong when a second one arrives.

### Reporting

```json theme={null}
POST /v1/conversations/3390/reports
{ "data": { "type": "reports", "attributes": {
  "reason": "off_platform_payment",
  "note": "They asked me to pay by bank transfer."
}, "relationships": {
  "message": { "data": { "type": "messages", "id": "5512" } }
}}}
```

The `message` relationship is optional — "this conversation is a problem" is a real
report, and making somebody pick a line loses the ones that are about a pattern. When it
is there it has to be a message of this thread, or `422 message_not_in_conversation`.

What was said is copied into the report as it stood, so a report stays actionable after a
moderator removes the message or an account takes the thread with it.

Either side may report. Reporting the same thread twice is allowed: the second report
usually carries what the first was missing. Past ten in an hour the answer is
`429 too_many_reports`, with `Retry-After` and `meta.retryAfter`.

The `201` carries a `self` link the reporter can read back. Nobody else can — not the
other side, not the shop. There is no list, and a report cannot be withdrawn or edited.

### Blocking

```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, so blocking the person who sent the message would leave their colleagues able
to send the next one. Blocking twice answers `200` with the block that is already there.
These are the account's own, so they ask for `account:read` and `account:write` rather than
the messaging scopes.

`GET /v1/me/blocks?include=seller` is the "Blocked" screen, and
`DELETE /v1/me/blocks/{id}` unblocks.

> [!NOTE]
>
> **What a block does.** Every thread between the two is closed, in both directions, and
> a new one cannot be opened — so a blocked shop cannot reach somebody by starting a
> fresh thread about another watch. Messages already sent stay where they are: a block
> was never a deletion.
>
> **What it does not do.** It does not hide the shop's watches. Somebody who blocked a
> shop after an argument may still want the watch they were arguing about.

### A shop blocking a buyer

The other direction, from the seller environment. Named by a thread, because that is how a
shop knows its buyers:

```json theme={null}
POST /v1/sellers/7/buyer-blocks
{ "data": { "type": "buyer-blocks", "relationships": {
  "conversation": { "data": { "type": "conversations", "id": "16916" } }
}}}
```

Scope `messages:write`. The **shop** blocks the **buyer** of that thread — not only the
thread: every thread between the two closes, both ways, and the buyer cannot open a new one.
The buyer still sees the shop's watches. Twice is once (`200`), another shop's thread is
`404`, and a buyer who works at the shop is `422 cannot_block_your_own_staff`.

`GET /v1/sellers/7/buyer-blocks` is the shop's "Blocked buyers" screen, with `buyerName` on
each row; `?filter[conversation]=16916` finds the block on the buyer of any thread, which is
what a thread's menu needs to offer an unblock; `DELETE /v1/sellers/7/buyer-blocks/{id}`
unblocks.

### What a blocked thread looks like

A thread carries `blocked`, which is what tells the app not to draw a reply box, whichever
side blocked. `blockedByYou` says whether it was the caller's side — the buyer in their own
thread, the shop for its staff — so `true` means draw the unblock button and `false` means
there is nothing for them to do. Write into one anyway and the answer is
`403 conversation_blocked` with the same answer in `meta.blockedByYou`, and words that fit:
"You blocked this business", "Your shop blocked this person", or that the other side does
not want to be contacted. Offers are refused the same
way, including accepting one, because accepting sends the seller's own proposal.

The same `403 conversation_blocked` (with `blockedByYou: false`) answers anybody writing in a
thread whose buyer's account an administrator blocked. That is not a block between the two, but
it closes the thread just the same, so `blocked` is `true` there too, with `blockedByYou`
`false`: there is nothing for either side to undo.


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