WatchFlow Website API (v1)#
Put your live WatchFlow inventory on your own website, in your own design.
Your inventory already lives in WatchFlow. This API lets your site read it — live, no export, no CSV, no copy-paste — and render it with your own HTML, your own fonts, your own layout. When you mark a watch sold in WatchFlow, it disappears from your site on the next page load. When you add one, it appears.
- Base URL:
https://mywatchflow.com/api/public/v1 - Method:
GETonly. This API can never change anything in your account. - Auth: an API key you mint yourself, sent in the
X-API-Keyheader. - Format: JSON, UTF-8.
Last synced with the implementation: 2026-08-28 (routes/public-api.js,routes/api-keys.js,middleware/publicApiKey.js,utils/publicInventoryView.js,utils/publicApiKeys.js).
1. What it will and will not give you#
It returns only what you have already published. An item appears in this API if — and only if — you have turned on Show on retail website for it (the same switch that puts it on your WatchFlow storefront). Everything else in your book is invisible here. Unpublish an item and it is gone from the API immediately, photos included.
It never returns your private data. The response is built from a fixed allow-list of fields (§7). There is no parameter, no flag, and no version of this API that returns your cost, your expenses, your profit or margin, what you sold something for, who you bought it from, who you sold it to, your internal notes, your serial numbers, your storage location, or any contact record. Those columns are not in the query that builds the response.
It is per-dealer. A key only ever reaches its own account's book. There is no endpoint that takes another dealer's id.
2. Get a key#
In WatchFlow, sign in as the account owner and open Settings → API. Click Create key, choose Retail, and add the address(es) your site is served from. The key is shown once, on screen, with a copy button and a ready-made curl line to hand to whoever builds your site.
You never need to call the endpoint below by hand — it is documented only so you know exactly what the screen does:
POST /api/api-keys
{ "name": "Main website", "allowedOrigins": ["https://mydealership.com"] }Only the account owner can mint, rename or revoke a key. A key publishes the whole team's book, so that authority stays with whoever owns the book; team members get 403 OWNER_ONLY.
You may hold up to 10 active keys at once (revoked ones do not count). Past that, minting answers 400 KEY_LIMIT_REACHED — revoke one you no longer use. Each key may register up to 10 origins.
The response contains your key once:
{
"key": "wfk_9tK2mQ0xR7bV3nL5pA8sD1fG4hJ6kZ...",
"apiKey": {
"id": 12,
"name": "Main website",
"key_prefix": "wfk_9tK2mQ0x",
"allowed_origins": ["https://mydealership.com"],
"scopes": ["inventory:read"],
"revoked": false,
"last_used_at": null,
"request_count": 0,
"created_at": "2026-08-11 14:02:11",
"destination_id": 41,
"connected": true,
"serving": true
},
"destination": { "id": 41, "platform": "Website", "connected": true },
"warning": "Copy this key now — it is stored only as a hash and cannot be shown again..."
}Copy it now. We store only a SHA-256 digest of it, so we cannot show it to you again and we cannot recover it if you lose it — revoke it and mint another.
| Endpoint | What it does |
|---|---|
GET /api/api-keys | List your keys (prefix, origins, last used, request count). Never the secret. |
POST /api/api-keys | Mint a key. Returns the plaintext once. |
PATCH /api/api-keys/:id | Rename, or change the registered origins (e.g. you moved domain) without re-deploying the key. |
DELETE /api/api-keys/:id | Revoke. Takes effect on the very next request. |
Notes:
- Only the account owner can manage keys. A key reads the whole team's published book, so the authority to issue one belongs to whoever owns the book — not to a member whose own access may be deliberately narrower.
- Requires a Professional or Team plan, the same as your WatchFlow storefront — and it keeps requiring one. The plan is re-checked on every read, not just when the key was minted, so if the subscription lapses the API stops answering (see §2.1). It resumes by itself once billing is fixed; you do not re-mint anything.
- Max 10 active keys. Revoked keys do not count and are kept forever, so a leaked key can never be resurrected.
2.1 Your website is a Destination#
Minting a key creates a Website card in Destinations, alongside Telegram and WhatsApp. That card is the on/off switch for every key you have:
| You do this | What happens to the key |
|---|---|
| Mint a key | The Website card is created (or switched back on) and the key is attached to it. |
| Disconnect the card | Every key on it stops answering on the very next request — 403 DESTINATION_NOT_CONNECTED. The keys are untouched. |
| Reconnect the card | The same keys start answering again. Nothing to re-deploy. |
| Revoke your last key | The card flips to disconnected (it is not serving anything). It stays in the list; minting again reconnects it. |
| Delete the card | Every key on it is revoked permanently. This is the destructive option — use Disconnect if you only want to pause. |
Disconnect is the fast, reversible way to take your website's inventory feed offline — no code change on your site, no re-minting. It is instant in both directions; there is no cache to wait out.
The Website card cannot be added by hand in Destinations. It appears when you mint a key and is managed from there.
Revoking#
Settings → API → Revoke (or DELETE /api/api-keys/:id). There is no cache to wait out — the next request carrying that key gets 401. Use one key per site or per integration, so you can kill one without taking down the others.
Revoking is permanent. The key's fingerprint stays on file precisely so that exact key can never be made valid again, which is what makes a leak survivable.
Rotating a key (it leaked, or a developer committed it to a public repo)#
This is a two-minute job with no downtime worth worrying about, and it is the right response every time — never "we'll change it later":
- Settings → API → Create key. Register the same origins. Copy it.
- Paste the new key into your site and deploy.
- Revoke the old key. Do this last, so your site is never without one.
If the leak is urgent and you would rather be down than exposed, reverse it: revoke first, then mint and redeploy. And if you would rather take the whole feed offline while you think, use the Website API switch (§2.1) — it stops every key instantly and reverses just as fast.
A leaked retail key is not a breach of anything private: it reaches only the items you already publish on your public website, and the origin allow-list stops it working from anybody else's page. Rotate it anyway. A leaked wholesale key is serious — trade pricing is not public — which is exactly why a wholesale key can never be used from browser code in the first place.
3. Authentication#
Send the key in a header. Never in the URL — query strings end up in server logs, browser history, and the Referer of every outbound link on your page.
X-API-Key: wfk_9tK2mQ0xR7bV3nL5pA8sD1fG4hJ6kZ...Authorization: Bearer wfk_… is accepted as an equivalent.
| Status | code | Meaning |
|---|---|---|
401 | API_KEY_MISSING | No key, or not a WatchFlow key. |
401 | API_KEY_INVALID | Unknown, revoked, or the account is closed. |
403 | ORIGIN_NOT_ALLOWED | Valid key, but this browser origin is not registered on it. |
403 | KEY_SCOPE_CHANGED | The account joined or left a team since the key was issued. Mint a new one. |
403 | SCOPE_REQUIRED | You asked for the wholesale channel with a retail-only key. |
403 | DESTINATION_NOT_CONNECTED | The Website card in Destinations is switched off (see §2.1). Reconnect it — the key itself is fine. |
403 | SUBSCRIPTION_INACTIVE | The WatchFlow subscription lapsed. The feed resumes automatically once billing is fixed. |
403 | PLAN_UPGRADE_REQUIRED | The account is no longer on Professional or Team. |
429 | RATE_LIMITED | See §5. |
The three 403s above are the states a live, correct key can be in. Handle them by leaving the last good render on the page rather than emptying your catalog — they are all recoverable inside WatchFlow without touching your site. SUBSCRIPTION_INACTIVE and PLAN_UPGRADE_REQUIRED are the same codes and bodies the WatchFlow app itself returns, so one branch covers both surfaces. Note the plan verdict is cached for up to a minute: a lapse (or a repair) can take that long to show up. The Destinations switch is not cached at all.
4. CORS: register your domain#
There is no wildcard. When you mint a key you register the exact origins your site is served from, and those are the only origins a browser will get a response for.
"allowedOrigins": ["https://mydealership.com", "https://www.mydealership.com"]- Give the exact scheme + host — no paths, no wildcards.
https://mydealership.comandhttps://www.mydealership.comare different origins; register both. - A page address is refused, not quietly trimmed. Pasting
https://mydealership.com/watchesreturns400 INVALID_ORIGINand tells you to registerhttps://mydealership.com, which covers every page on the site. We would rather ask than silently register something broader than you typed. (Capitalisation, a bare trailing slash and an explicit:443are fine — they mean the same origin and are tidied up for you.) - Plain
http://is accepted only forlocalhost, so you can develop locally. - A request with no
Originheader at all — anything called from your own server, or curl — is not a browser cross-origin request and needs no registration. Register origins only if you are calling from browser JavaScript.
Where should the key live: browser or server?#
Both work. The trade-off:
In browser JavaScript (simplest). Anyone who views your page source can read the key. That is an acceptable trade for a retail key, because everything it reaches is already on your public website — the worst case is a tidier way to scrape a catalog you publish anyway. The origin allow-list keeps other sites from using it in a browser.
On your server (a small proxy, §9). The key never reaches the public. Use this if you would rather not publish the key at all, and required for a wholesale key — a wholesale-scoped key refuses to register any browser origin, because trade pricing is not public and a key in page source is.
5. Rate limits#
Per key, not per IP, so your traffic is never affected by anyone else's:
- 120 requests / minute per key
- 600 requests / minute per IP as a coarse flood guard in front of it
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (exposed to browser JS). Over the limit is 429.
Ask for a whole page of items (per_page=100) rather than fetching items one at a time, and cache the result in your own page for a few seconds if you render it on every request. A typical catalog page costs one call.
6. Endpoints#
GET /inventory — your published catalog#
curl -H "X-API-Key: $WF_KEY" \
"https://mywatchflow.com/api/public/v1/inventory?per_page=24&sort=newest"| Parameter | Values | Default |
|---|---|---|
page | 1-based page number | 1 |
per_page | 1–100 | 24 |
sort | newest, oldest, price_asc, price_desc, brand | newest |
status | available, reserved, sold, all | the dealer's setting (see §7a) |
q | free text across brand / model / reference | — |
brand | exact brand, case-insensitive; comma-separate for several | — |
ref | exact reference | — |
condition | exact condition | — |
year | exact year | — |
item_kind | watch, accessory | both |
min_price, max_price | numbers, against the published price | — |
channel | retail, wholesale (needs the wholesale scope) | retail |
An unrecognised sort falls back to newest rather than erroring.
{
"data": [
{
"id": 4821,
"stock_id": "WF-1043",
"item_kind": "watch",
"brand": "Rolex",
"model": "Submariner Date",
"ref": "126610LN",
"year": "2023",
"condition": "Unworn",
"box_papers": "Full Set",
"description": "Unworn 2023 example, stickers intact.",
"status": "available",
"price": 15250,
"price_formatted": "$15,250.00",
"currency": "USD",
"specs": {
"case_size": "41mm",
"case_material": "Oystersteel",
"dial": "Black",
"bezel": "Ceramic",
"movement": "3235 Automatic",
"bracelet": "Oyster",
"water_resistance": "300m",
"bracelet_size": "",
"links": "",
"papers": "Card"
},
"photos": [
{
"id": 90211,
"url": "https://mywatchflow.com/api/public/v1/media/90211/Yk3f.../front.jpg",
"thumbnail_url": "https://mywatchflow.com/api/public/v1/media/90211/Yk3f.../thumb-front.jpg",
"is_cover": true
}
],
"primary_photo": "https://mywatchflow.com/api/public/v1/media/90211/Yk3f.../front.jpg",
"listed_at": "2026-07-30 09:12:04",
"sold_at": null
}
],
"pagination": { "page": 1, "per_page": 24, "total": 63, "total_pages": 3, "has_more": true },
"meta": { "channel": "retail", "api_version": "v1" }
}GET /inventory/:id — one piece#
Same object, under data. Use it for your detail page.
Deliberately more forgiving than the list: an item you published and then sold still resolves here (with status: "sold" and no price), so a link you put on your site, or a customer bookmarked, does not start 404-ing the day it sells. An id belonging to another dealer, or to an item you never published, returns a plain 404 — indistinguishable from an id that does not exist.
GET /inventory/brands — brand facets#
{ "data": [ { "brand": "Rolex", "count": 22 }, { "brand": "Omega", "count": 9 } ] }For building a filter nav without paging the whole catalog.
GET /ping — check your wiring#
Returns your key's name, scopes and registered origins. Start here when setting up.
GET /media/:photoId/:signature/:filename — photos#
You never build these URLs; they arrive inside each item, absolute and signed. They need no API key (an <img> tag cannot send headers), which is why the signature exists. Every request re-checks that the photo still belongs to an item you still publish — unpublish an item and its photos stop resolving within seconds. The same two gates as the JSON apply: photos stop resolving (plain 404) while the Website destination is disconnected, or while the subscription is lapsed.
7. Field reference — the complete allow-list#
These are all the fields that exist. There are no others.
| Field | Type | Why it is safe to publish | ||
|---|---|---|---|---|
id | number | Your own row id. Already the public key of your WatchFlow storefront URLs. You need a stable id to link and de-duplicate. | ||
stock_id | string | Your own SKU, the one printed on your own tags. Dealer-authored, non-financial, no third party in it. | ||
item_kind | "watch" \ | "accessory" | A layout hint so you can render two card types. | |
brand | string | Catalogue identity. On every storefront card. | ||
model | string | Catalogue identity. | ||
ref | string | Manufacturer reference — public product data by definition. | ||
year | string | Production year — public product data. | ||
condition | string | Public product data; your storefront prints it. | ||
box_papers | string | Full Set / Box Only / Papers Only / Watch Only. Already a badge on your storefront. | ||
description | string | Your public notes field — the one WatchFlow already copies into your public listing body. Your private notes and internal_notes are not this field and are never returned. | ||
status | "available" \ | "reserved" \ | "sold" | A three-word public vocabulary, not the internal one. "On Memo" reports as available (you still own it); "Returned to Vendor" has no mapping and such items appear nowhere. |
price | number \ | null | The retail price you publish — retail_price, falling back to asking (which WatchFlow labels "Retail Website Price"). Derived by the same code as your storefront, so the two always agree. null when there is nothing publishable to say, so your template can print "Inquire" instead of "$0". | |
price_formatted | string \ | null | The same number, formatted in its own currency. Convenience only. | |
currency | string | ISO code. The price is meaningless without it. No conversion is applied — face value in its own currency. | ||
specs.* | strings | case_size, case_material, dial, bezel, movement, bracelet, water_resistance, bracelet_size, links, papers. Physical measurements and descriptions of the object itself — the same table your storefront renders. | ||
photos[] | objects | id, url, thumbnail_url, is_cover. Signed URLs only; the uploader's user id and internal file metadata stay behind. | ||
primary_photo | string \ | null | Convenience: the cover photo's URL, or the first photo. | |
listed_at | string \ | null | When the piece entered your book. Needed for a stable "newest first" order; you decide whether to render it. | |
sold_at | string \ | null | Only ever populated for a sold item, which is only reachable if you publish your Sold showcase. Your storefront already shows this. |
Never returned, under any parameter#
cost · unit_cost · total_cost · purchase_expense · purchase_expenses · tag_price · target_wire_price · target_wholesale_price · sold_price · profit or margin of any kind · dealer_market_price (except on a wholesale-scoped key, §8) · purchase_from · purchase_date · source · source_name · any *_contact_id · consignment_* · traded_with_* · memo_* · vendor_* · notes · internal_notes · serial_number · storage · barcode · service_history · warranty_expiry · acquisition_type · disposal_type · user_id · team_id · sale_id · every contact, deal, invoice and payment record.
This is enforced as an allow-list in two layers (utils/publicInventoryView.js): the SQL names its columns, and the response object is built field by field. A column added to the inventory table by a future release cannot appear here until someone edits that file on purpose.
Prices you have switched off#
If you have turned Show prices off on your WatchFlow website, this API returns price: null too. It is not a side door around that decision.
7a. What the dealer chose to show#
status has no fixed default. When your request does not carry a status parameter, the API applies the dealer's own choice, set per key in Settings → API → What your website shows:
| Dealer setting | Default | Effect when on |
|---|---|---|
| Keep sold pieces on the site | off | Sold items stay in the feed, with status: "sold" and price: null |
| Show reserved pieces | on | Reserved items are included |
| Show pieces out on memo | on | On Memo items are included |
An explicit ?status= always wins. ?status=available, reserved, sold or all behave exactly as they always have, so nothing you have already built changes behaviour. Read this the other way round too: if your site hard-codes ?status=available, the dealer's Keep sold pieces on the site switch will appear to do nothing. Leave status off and let them drive it.
Sold items never carry a price — price and price_formatted are null — so a sold-piece showcase can't be scraped for what things went for.
8. The wholesale channel (optional)#
There are three shapes of key, chosen in Settings → API when you mint it:
| Access | scopes | Default channel | Can register browser origins? |
|---|---|---|---|
| Retail only | ["inventory:read"] | retail | Yes |
| Wholesale only | ["inventory:wholesale"] | wholesale | No |
| Retail + Wholesale | ["inventory:read", "inventory:wholesale"] | retail | No |
A wholesale-only key cannot reach the retail catalogue at all — asking for ?channel=retail with one gets 403 SCOPE_REQUIRED, the mirror of a retail key asking for wholesale. It is the right shape for a trade-only site: the key simply has no path to your public listings, so a bug on that site can't leak them.
The wholesale channel returns items flagged Show on wholesale, priced from dealer_market_price. That is trade pricing — on your storefront it sits behind a password. So:
- A retail-only key asking for
channel=wholesalegets403 SCOPE_REQUIRED. - Any key that can reach trade pricing cannot register browser origins, and the API refuses to create one that tries. Call it from your server, behind your own dealer login. This is enforced, not advised.
- Scopes cannot be added to an existing key. Widening what a secret can reach requires issuing a new secret.
9. Copy-paste: your inventory in your own markup#
Drop this into any page. The only WatchFlow-specific things are the key and the fetch — every tag, class and style below is yours to replace. There is no WatchFlow stylesheet, no iframe, and no widget.
<!-- Your markup. Style it however you like. -->
<div id="inventory" class="my-grid"></div>
<script>
(async function () {
const API = 'https://mywatchflow.com/api/public/v1';
const KEY = 'wfk_REPLACE_WITH_YOUR_KEY'; // register this page's origin on the key
const grid = document.getElementById('inventory');
const money = (item) =>
item.price_formatted || 'Price on request';
try {
const res = await fetch(`${API}/inventory?per_page=24&sort=newest`, {
headers: { 'X-API-Key': KEY }
});
if (!res.ok) throw new Error(`WatchFlow API ${res.status}`);
const { data } = await res.json();
if (!data.length) { grid.innerHTML = '<p>No pieces available right now.</p>'; return; }
grid.innerHTML = data.map(item => `
<article class="my-card">
${item.primary_photo
? `<img class="my-card__img" src="${item.primary_photo}" alt="${item.brand} ${item.model}" loading="lazy">`
: ''}
<h3 class="my-card__title">${item.brand} ${item.model}</h3>
${item.ref ? `<p class="my-card__ref">Ref. ${item.ref}</p>` : ''}
${item.year ? `<p class="my-card__year">${item.year}</p>` : ''}
<p class="my-card__price">${money(item)}</p>
${item.status === 'reserved' ? '<span class="my-card__tag">Reserved</span>' : ''}
<a class="my-card__cta" href="/watch.html?id=${item.id}">View details</a>
</article>
`).join('');
} catch (err) {
console.error(err);
grid.innerHTML = '<p>Our collection is briefly unavailable.</p>';
}
})();
</script>The template above interpolates values straight into HTML for brevity. If any of your inventory text can contain<or&, build the nodes withdocument.createElement/textContentinstead, or escape first — the same rule as any other data you render.
A detail page (/watch.html?id=…)#
const id = new URLSearchParams(location.search).get('id');
const res = await fetch(`${API}/inventory/${id}`, { headers: { 'X-API-Key': KEY } });
if (res.status === 404) { /* your own not-found page */ }
const { data: watch } = await res.json();
document.title = `${watch.brand} ${watch.model} — Ref. ${watch.ref}`;
// watch.photos -> your gallery
// watch.specs -> your spec table
// watch.description -> your copy block
// watch.status === 'sold' -> your SOLD treatment (price will be null)Server-side instead (Node), keeping the key private#
// Your server. The browser never sees the key.
app.get('/api/watches', async (req, res) => {
const r = await fetch('https://mywatchflow.com/api/public/v1/inventory?per_page=100', {
headers: { 'X-API-Key': process.env.WATCHFLOW_API_KEY }
});
res.set('Cache-Control', 'public, max-age=30').json(await r.json());
});Called this way there is no Origin header, so the key needs no registered origins at all.
Server-side in PHP (WordPress, Laravel, plain PHP)#
<?php
// Your server. The browser never sees the key.
$ch = curl_init('https://mywatchflow.com/api/public/v1/inventory?per_page=100');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('WATCHFLOW_API_KEY')],
]);
$body = curl_exec($ch);
curl_close($ch);
$data = json_decode($body, true)['data']; // then render $data in your templateThis is the shape a wholesale key must always use — it is server-side only and will refuse to register a browser origin.
Shopify, Wix, Squarespace, Webflow and other hosted builders#
You do not need a plugin or an app. All of these let you drop raw HTML into a page — Shopify calls it a Custom Liquid section, Wix an Embed / HTML iframe element, Squarespace a Code Block, Webflow an Embed:
- Add a custom-HTML / embed block to the page where the collection should go.
- Paste the §9 snippet into it and replace the key.
- Register the address your published site is served from, not the editor preview. For a live Shopify store that is
https://yourstore.com; before you attach a custom domain it ishttps://yourstore.myshopify.com. Wix and Squarespace preview on their own editor domains, so publish first and check the address bar. If in doubt, register both — you may have up to 10 origins, and you can edit them later without re-minting.
Two things to know about embeds:
- Some builders render an embed inside an
<iframe>on a different origin from the page around it (Wix's HTML element does this). The origin that matters is the one the embedded document is served from. If your snippet reports403 ORIGIN_NOT_ALLOWED, open your browser's dev tools, look at the request'sOriginheader, and register exactly that. - Builders that only offer an iframe embed cannot share your page's styles. If you want the grid to inherit your theme's fonts and colours, use a block that injects HTML into the page itself (Shopify Custom Liquid, Squarespace Code Block, Webflow Embed) rather than an iframe element.
10. Checklist#
- In WatchFlow, Settings → API → Create key as the account owner, registering your site's exact origin(s). Copy the key — it is shown once.
curl -H "X-API-Key: …" https://mywatchflow.com/api/public/v1/ping— expect{"ok":true}.- Confirm the items you want are switched on for Show on retail website.
- Paste the snippet in §9, replace the key, and restyle the markup as your own.
- Change something in WatchFlow and reload your page — it should be there.
- Handle the three recoverable
403s (§3) by keeping your last good render on the page. Your feed is off, not broken.
Losing the key later is a non-event: revoke it, mint another, redeploy.
Taking the feed down for a while is also a non-event: open Destinations and disconnect the Website card. Reconnect when you want it back.