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 →).
Answered from that end. In the database these are two columns; a client never has
to know that.
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.
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.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.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.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.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.
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.
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 withavailabilityOnRequest: 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:
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
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.buyer, seller or system. A message with no author is ours — there is one,
the payment confirmation — and it is rendered differently.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.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.The language the message was written in, for “translated from German”. Null on
older messages, and when there is no translation for you.
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.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:
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 backpayable: 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:
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:
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
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".
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 — withPATCH /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 notPOST /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
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
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
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: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 carriesblocked, 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.