Skip to main content
Scopes messages:read and messages:write.

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 →).
string
buyer or seller — which end of this thread you are on.
boolean
Answered from that end. In the database these are two columns; a client never has to know that.
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.
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.
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.
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.
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.
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.
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.
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

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

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.
string
buyer, seller or system. A message with no author is ours — there is one, the payment confirmation — and it is rendered differently.
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: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.
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.
string
The language the message was written in, for “translated from German”. Null on older messages, and when there is no translation for you.
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.
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 first — the bytes, or a sourceUrl we fetch — and name what comes back:
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:
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:
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

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".
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: 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:
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

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

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

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