curl --request POST \
--url https://{host}/v1/me/saved-searches \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/vnd.api+json' \
--data '
{
"data": {
"type": "saved-searches",
"attributes": {
"name": "Blue Submariner",
"words": "submariner",
"filters": {
"brand": [
"Rolex"
],
"attributes.dial": [
"BLUE"
],
"price": {
"min": 500000,
"max": 1500000
}
},
"interval": "instant"
}
}
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/vnd.api+json'},
body: JSON.stringify({
data: {
type: 'saved-searches',
attributes: {
name: 'Blue Submariner',
words: 'submariner',
filters: {
brand: ['Rolex'],
'attributes.dial': ['BLUE'],
price: {min: 500000, max: 1500000}
},
interval: 'instant'
}
}
})
};
fetch('https://{host}/v1/me/saved-searches', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://{host}/v1/me/saved-searches",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'data' => [
'type' => 'saved-searches',
'attributes' => [
'name' => 'Blue Submariner',
'words' => 'submariner',
'filters' => [
'brand' => [
'Rolex'
],
'attributes.dial' => [
'BLUE'
],
'price' => [
'min' => 500000,
'max' => 1500000
]
],
'interval' => 'instant'
]
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/vnd.api+json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}import requests
url = "https://{host}/v1/me/saved-searches"
payload = { "data": {
"type": "saved-searches",
"attributes": {
"name": "Blue Submariner",
"words": "submariner",
"filters": {
"brand": ["Rolex"],
"attributes.dial": ["BLUE"],
"price": {
"min": 500000,
"max": 1500000
}
},
"interval": "instant"
}
} }
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/vnd.api+json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"data": {
"type": "saved-searches",
"id": "204",
"attributes": {
"name": "Submariner under €14k",
"words": "submariner",
"filters": {
"brand": [
"Rolex"
],
"price": {
"min": 0,
"max": 1400000
}
},
"interval": "daily",
"push": true,
"url": "https://wristler.eu/watches?filters%5Bbrand%5D%5B0%5D=Rolex&filters%5Bprice%5D%5Bmin%5D=0&filters%5Bprice%5D%5Bmax%5D=14000&query=submariner&sortBy=watches%3Acreated_at%3Adesc",
"newResults": 3,
"createdAt": "2026-09-01T05:22:00.000000Z",
"updatedAt": "2026-09-01T05:22:00.000000Z"
},
"links": {
"self": "https://api.wristler.eu/v1/me/saved-searches/204"
}
}
}{
"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": "409",
"code": "saved_search_exists",
"title": "Conflict",
"detail": "It’s saved as “Blue Submariner” with daily notifications. Saving it again means you’ll get the same alerts twice.",
"meta": {
"savedSearch": "204",
"name": "Blue Submariner",
"interval": "daily"
}
}
]
}{
"errors": [
{
"status": "422",
"code": "broad_search",
"title": "Unprocessable Entity",
"detail": "Confirm that you want to receive these notifications, or narrow down your search.",
"meta": {
"matchingWatches": 212,
"reachedLimit": false,
"expectedEmails": {
"instant": 140,
"daily": 30,
"weekly": 5
},
"threshold": 50
}
}
]
}Save search
Scope: account:write
In 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 — a list of values per
facet, and price as { min, max } in cents, either end of which may be left out
(“from €5,000” is { min: 500000 }). They are not checked against the list
of facets; one the matcher does not know is ignored. What is stored
underneath is the expression the matcher hands the index, which stays ours — a
client should not have to write one to be told about a new Submariner.
interval is the mail: instant, daily, weekly, or none for no mail.
push is the phone, per search, and true when left out. A search with
interval: none and push: true only pushes, and is checked with the instant ones
(every 15 minutes); with both off it is kept and sends nothing. interval is
required; name and words are up to 255 characters. Leave name out and it
is named after the date, as on the website.
Two refusals, the website’s two checks before it saves:
409 saved_search_exists— the person already has a search for the same watches: the same filters in any order and the same words in any case.metanames it (savedSearch, its id;name;interval) anddetailsays so in the reader’s language. Open that one rather than saving a second, which would send every alert twice. There is no override, as on the website.422 broad_search— the search matched at leastmeta.threshold(50) watches in the past month, so it may send many mails or pushes.metahasmatchingWatches(capped at 1000,reachedLimittrue when it was),expectedEmailsper interval (instant,daily,weekly: how many mails last month would have sent) andthreshold. Show that, and send the same request again withacceptsManyEmails: trueto save it anyway — or narrow it down. Not asked of a search withinterval: noneandpush: false, and skipped when the search service cannot estimate it, as on the website.
acceptsManyEmails is sent, never read back.
curl --request POST \
--url https://{host}/v1/me/saved-searches \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/vnd.api+json' \
--data '
{
"data": {
"type": "saved-searches",
"attributes": {
"name": "Blue Submariner",
"words": "submariner",
"filters": {
"brand": [
"Rolex"
],
"attributes.dial": [
"BLUE"
],
"price": {
"min": 500000,
"max": 1500000
}
},
"interval": "instant"
}
}
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/vnd.api+json'},
body: JSON.stringify({
data: {
type: 'saved-searches',
attributes: {
name: 'Blue Submariner',
words: 'submariner',
filters: {
brand: ['Rolex'],
'attributes.dial': ['BLUE'],
price: {min: 500000, max: 1500000}
},
interval: 'instant'
}
}
})
};
fetch('https://{host}/v1/me/saved-searches', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://{host}/v1/me/saved-searches",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'data' => [
'type' => 'saved-searches',
'attributes' => [
'name' => 'Blue Submariner',
'words' => 'submariner',
'filters' => [
'brand' => [
'Rolex'
],
'attributes.dial' => [
'BLUE'
],
'price' => [
'min' => 500000,
'max' => 1500000
]
],
'interval' => 'instant'
]
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/vnd.api+json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}import requests
url = "https://{host}/v1/me/saved-searches"
payload = { "data": {
"type": "saved-searches",
"attributes": {
"name": "Blue Submariner",
"words": "submariner",
"filters": {
"brand": ["Rolex"],
"attributes.dial": ["BLUE"],
"price": {
"min": 500000,
"max": 1500000
}
},
"interval": "instant"
}
} }
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/vnd.api+json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"data": {
"type": "saved-searches",
"id": "204",
"attributes": {
"name": "Submariner under €14k",
"words": "submariner",
"filters": {
"brand": [
"Rolex"
],
"price": {
"min": 0,
"max": 1400000
}
},
"interval": "daily",
"push": true,
"url": "https://wristler.eu/watches?filters%5Bbrand%5D%5B0%5D=Rolex&filters%5Bprice%5D%5Bmin%5D=0&filters%5Bprice%5D%5Bmax%5D=14000&query=submariner&sortBy=watches%3Acreated_at%3Adesc",
"newResults": 3,
"createdAt": "2026-09-01T05:22:00.000000Z",
"updatedAt": "2026-09-01T05:22:00.000000Z"
},
"links": {
"self": "https://api.wristler.eu/v1/me/saved-searches/204"
}
}
}{
"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": "409",
"code": "saved_search_exists",
"title": "Conflict",
"detail": "It’s saved as “Blue Submariner” with daily notifications. Saving it again means you’ll get the same alerts twice.",
"meta": {
"savedSearch": "204",
"name": "Blue Submariner",
"interval": "daily"
}
}
]
}{
"errors": [
{
"status": "422",
"code": "broad_search",
"title": "Unprocessable Entity",
"detail": "Confirm that you want to receive these notifications, or narrow down your search.",
"meta": {
"matchingWatches": 212,
"reachedLimit": false,
"expectedEmails": {
"instant": 140,
"daily": 30,
"weekly": 5
},
"threshold": 50
}
}
]
}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