curl --request POST \
--url https://{host}/v1/conversations/{conversationId}/messages \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/vnd.api+json' \
--data '
{
"data": {
"type": "messages",
"attributes": {
"body": "Yes, still available. Here is a photograph of the clasp.",
"attachmentHandles": [
"/wristler-image-storage/clasp-20240101.jpeg"
]
}
}
}
'{
"data": {
"type": "messages",
"id": "88120",
"attributes": {
"body": null,
"author": "seller",
"mine": false,
"removed": false,
"priceProposal": {
"amount": 1245000,
"currency": "EUR",
"shippingCosts": 0,
"saving": 50000,
"savingPercentage": 4,
"from": "seller",
"status": "pending",
"state": "open",
"payable": true,
"open": true,
"generatedBody": true,
"expiresAt": "2026-09-23T07:30:11.000000Z",
"acceptedAs": null,
"checkoutUrl": "https://wristler.eu/checkout/91827-rolex-submariner-date?expires=1790062211&price=1245000&signature=…"
},
"translatedBody": null,
"translationLocale": null,
"translationSourceLocale": null,
"translationPending": false,
"createdAt": "2026-09-21T07:30:11.000000Z"
},
"relationships": {
"attachments": {
"links": {
"related": "https://api.wristler.eu/v1/conversations/3312/messages/88120/attachments"
}
}
},
"links": {
"self": "https://api.wristler.eu/v1/conversations/3312/messages/88120"
}
}
}{
"errors": [
{
"status": "404",
"code": "watch_reserved",
"title": "Not Found",
"detail": "<string>",
"meta": {},
"source": {
"pointer": "/data/attributes/reference",
"parameter": "<string>"
}
}
],
"jsonapi": {
"version": "1.0"
}
}{
"errors": [
{
"status": "404",
"code": "watch_reserved",
"title": "Not Found",
"detail": "<string>",
"meta": {},
"source": {
"pointer": "/data/attributes/reference",
"parameter": "<string>"
}
}
],
"jsonapi": {
"version": "1.0"
}
}{
"errors": [
{
"status": "404",
"code": "watch_reserved",
"title": "Not Found",
"detail": "<string>",
"meta": {},
"source": {
"pointer": "/data/attributes/reference",
"parameter": "<string>"
}
}
],
"jsonapi": {
"version": "1.0"
}
}Send message
Scope: messages:write
body is plain text, and may be left out when attachmentHandles names a file —
a photo on its own is a message, as in the website’s chat; it then comes back as
null, and a thread list’s excerpt says “Photo” / “2 photos” in your language.
There is no minimum length, as on the website; at most 5000 characters. The column holds HTML because
the website puts it there; accepting markup from a client would mean trusting
it to sanitise on our behalf. Links to anywhere but Wristler are taken out
before the message is stored.
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, exactly as from the website.
Attachments are handles, at most 4 of them. Send the file to
POST /v1/uploads first and put the handle it answers with in
attachmentHandles. What comes back on the message is the attachments
relationship, so ask for the files themselves with include=attachments.
An offer
priceProposal: { amount } in cents makes the message an offer, and either side
may send one — but they are not the same thing, and the difference is the whole
design:
- A seller’s offer is binding. Sending it is the agreement: it comes back
payable: truewith acheckoutUrl, it stands 48 hours, and a new one replaces the one before it. - A buyer’s offer is a request. It comes back
status: "pending",payable: falseandexpiresAt: null— a question has no clock, so it stands until the seller answers it, the buyer withdraws it, or the seller sends a proposal of their own, which makes it lapse asstatus: "expired". A buyer may have one outstanding at a time.
A checkout only ever charges at a seller’s proposal. Accepting a buyer’s offer
does not make the request payable: it makes the seller send their own proposal for
that amount, and the request points at it with acceptedAs. So a seller who wants
a different number does not accept — they send a proposal, which is the counter.
A buyer is held to the seller’s own floor, which is minimumProposal on the
watch — it was already there: the floor in cents, or null when there is nothing
to offer on (this business does not take offers, the watch cannot be bought, or it is
priced on request). Read it from the listing rather than working it out from
maximumDiscount: the rounding to whole euros is part of the rule, and the floor is
never under 75000 (€750), the least any offer may be — a client doing its own
arithmetic would refuse amounts we accept, or accept ones we refuse.
minimumProposal: null means no offer is possible for a buyer on that listing, and
a buyer’s offer on one is refused with 409 offers_not_accepted whatever the amount.
Do not read null as “no floor”.
A seller is held to the website’s proposal form, and nothing else: the watch is
still for sale (not sold, offline or deleted), and amount is at least 75000
(€750), the least any offer may be from either side. Their own floor and whether the business takes offers are about what a
buyer may ask, so they do not apply — a seller may name a price under the floor, on
a listing priced on request, or with offers switched off. Under €750 is an ordinary
422 validation error pointing at /data/attributes/priceProposal/amount.
Shipping, from a seller. priceProposal: { amount, shippingCosts }: what the buyer
pays for shipping with this proposal, in cents, 0 (free) to 100000 — the same
limits as the website’s form. Leave it out and the listing’s own shipping costs apply.
It comes back on the message as priceProposal.shippingCosts. A buyer’s offer is an
amount only: shippingCosts from a buyer is a 422.
body is optional beside a buyer’s offer: leave it out and we write the sentence
the website has always sent (generatedBody: true), or send your own words and they
are the message. A seller’s proposal carries no words: a body sent with a seller’s
proposal is refused with 422, pointer /data/attributes/body — the card is drawn from
priceProposal, so send the words as a message of their own. attachmentHandles with an
offer, from either side, is refused with 422, pointer /data/attributes/attachmentHandles.
| Refusal | Status | code |
|---|---|---|
| This business does not take offers, or the listing takes none (buyer’s offer) | 409 | offers_not_accepted |
| Below the seller’s floor (buyer’s offer) | 422 | below_minimum |
| The watch cannot be bought (buyer’s offer), or is sold, offline or deleted (seller’s offer) | 422 | watch_not_for_sale |
The buyer already has one waiting (meta.offer is its message id) | 409 | offer_already_open |
| The thread is closed by a block, either way, or the buyer’s account is blocked | 403 | conversation_blocked |
A body with a seller’s proposal (pointer /data/attributes/body) | 422 | — |
attachmentHandles with an offer (pointer /data/attributes/attachmentHandles) | 422 | — |
shippingCosts with a buyer’s offer (pointer /data/attributes/priceProposal/shippingCosts) | 422 | — |
curl --request POST \
--url https://{host}/v1/conversations/{conversationId}/messages \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/vnd.api+json' \
--data '
{
"data": {
"type": "messages",
"attributes": {
"body": "Yes, still available. Here is a photograph of the clasp.",
"attachmentHandles": [
"/wristler-image-storage/clasp-20240101.jpeg"
]
}
}
}
'{
"data": {
"type": "messages",
"id": "88120",
"attributes": {
"body": null,
"author": "seller",
"mine": false,
"removed": false,
"priceProposal": {
"amount": 1245000,
"currency": "EUR",
"shippingCosts": 0,
"saving": 50000,
"savingPercentage": 4,
"from": "seller",
"status": "pending",
"state": "open",
"payable": true,
"open": true,
"generatedBody": true,
"expiresAt": "2026-09-23T07:30:11.000000Z",
"acceptedAs": null,
"checkoutUrl": "https://wristler.eu/checkout/91827-rolex-submariner-date?expires=1790062211&price=1245000&signature=…"
},
"translatedBody": null,
"translationLocale": null,
"translationSourceLocale": null,
"translationPending": false,
"createdAt": "2026-09-21T07:30:11.000000Z"
},
"relationships": {
"attachments": {
"links": {
"related": "https://api.wristler.eu/v1/conversations/3312/messages/88120/attachments"
}
}
},
"links": {
"self": "https://api.wristler.eu/v1/conversations/3312/messages/88120"
}
}
}{
"errors": [
{
"status": "404",
"code": "watch_reserved",
"title": "Not Found",
"detail": "<string>",
"meta": {},
"source": {
"pointer": "/data/attributes/reference",
"parameter": "<string>"
}
}
],
"jsonapi": {
"version": "1.0"
}
}{
"errors": [
{
"status": "404",
"code": "watch_reserved",
"title": "Not Found",
"detail": "<string>",
"meta": {},
"source": {
"pointer": "/data/attributes/reference",
"parameter": "<string>"
}
}
],
"jsonapi": {
"version": "1.0"
}
}{
"errors": [
{
"status": "404",
"code": "watch_reserved",
"title": "Not Found",
"detail": "<string>",
"meta": {},
"source": {
"pointer": "/data/attributes/reference",
"parameter": "<string>"
}
}
],
"jsonapi": {
"version": "1.0"
}
}Authorizations
Headers
Optional. A UUID per logical operation, not per attempt: a retry carrying the same key gets the first attempt's answer back, headers and all, rather than performing the write twice. Honoured for 24 hours.
A refusal is an answer. A 422 is remembered like any other, so sending a
corrected body under the same key answers 422 idempotency_key_reuse rather
than accepting the correction — the point of a key is that one key means one
decision. Use a new key for a new attempt at the request, and keep the old one
only for retrying the same bytes after a timeout.
A 5xx is the exception: nothing was decided, so the key is released and the
same one may be retried.
| Refusal | Status | code |
|---|---|---|
| The same key with a different body | 422 | idempotency_key_reuse |
| The first request with this key is still running | 409 | idempotency_in_progress |
| The key's record vanished between two requests: retry with a new key | 409 | idempotency_conflict |
| A key longer than 191 characters | 400 | idempotency_key_invalid |
A replayed answer carries Idempotent-Replay: true.
191