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.

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.

EndpointWhat it does
GET /api/api-keysList your keys (prefix, origins, last used, request count). Never the secret.
POST /api/api-keysMint a key. Returns the plaintext once.
PATCH /api/api-keys/:idRename, or change the registered origins (e.g. you moved domain) without re-deploying the key.
DELETE /api/api-keys/:idRevoke. Takes effect on the very next request.

Notes:

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 thisWhat happens to the key
Mint a keyThe Website card is created (or switched back on) and the key is attached to it.
Disconnect the cardEvery key on it stops answering on the very next request — 403 DESTINATION_NOT_CONNECTED. The keys are untouched.
Reconnect the cardThe same keys start answering again. Nothing to re-deploy.
Revoke your last keyThe card flips to disconnected (it is not serving anything). It stays in the list; minting again reconnects it.
Delete the cardEvery 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":

  1. Settings → API → Create key. Register the same origins. Copy it.
  2. Paste the new key into your site and deploy.
  3. 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.

StatuscodeMeaning
401API_KEY_MISSINGNo key, or not a WatchFlow key.
401API_KEY_INVALIDUnknown, revoked, or the account is closed.
403ORIGIN_NOT_ALLOWEDValid key, but this browser origin is not registered on it.
403KEY_SCOPE_CHANGEDThe account joined or left a team since the key was issued. Mint a new one.
403SCOPE_REQUIREDYou asked for the wholesale channel with a retail-only key.
403DESTINATION_NOT_CONNECTEDThe Website card in Destinations is switched off (see §2.1). Reconnect it — the key itself is fine.
403SUBSCRIPTION_INACTIVEThe WatchFlow subscription lapsed. The feed resumes automatically once billing is fixed.
403PLAN_UPGRADE_REQUIREDThe account is no longer on Professional or Team.
429RATE_LIMITEDSee §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"]

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:

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"
ParameterValuesDefault
page1-based page number1
per_page1–10024
sortnewest, oldest, price_asc, price_desc, brandnewest
statusavailable, reserved, sold, allthe dealer's setting (see §7a)
qfree text across brand / model / reference
brandexact brand, case-insensitive; comma-separate for several
refexact reference
conditionexact condition
yearexact year
item_kindwatch, accessoryboth
min_price, max_pricenumbers, against the published price
channelretail, 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.

FieldTypeWhy it is safe to publish
idnumberYour own row id. Already the public key of your WatchFlow storefront URLs. You need a stable id to link and de-duplicate.
stock_idstringYour 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.
brandstringCatalogue identity. On every storefront card.
modelstringCatalogue identity.
refstringManufacturer reference — public product data by definition.
yearstringProduction year — public product data.
conditionstringPublic product data; your storefront prints it.
box_papersstringFull Set / Box Only / Papers Only / Watch Only. Already a badge on your storefront.
descriptionstringYour 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.
pricenumber \nullThe retail price you publishretail_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_formattedstring \nullThe same number, formatted in its own currency. Convenience only.
currencystringISO code. The price is meaningless without it. No conversion is applied — face value in its own currency.
specs.*stringscase_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[]objectsid, url, thumbnail_url, is_cover. Signed URLs only; the uploader's user id and internal file metadata stay behind.
primary_photostring \nullConvenience: the cover photo's URL, or the first photo.
listed_atstring \nullWhen the piece entered your book. Needed for a stable "newest first" order; you decide whether to render it.
sold_atstring \nullOnly 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 settingDefaultEffect when on
Keep sold pieces on the siteoffSold items stay in the feed, with status: "sold" and price: null
Show reserved piecesonReserved items are included
Show pieces out on memoonOn 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:

AccessscopesDefault channelCan register browser origins?
Retail only["inventory:read"]retailYes
Wholesale only["inventory:wholesale"]wholesaleNo
Retail + Wholesale["inventory:read", "inventory:wholesale"]retailNo

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:


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 with document.createElement / textContent instead, 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 template

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

  1. Add a custom-HTML / embed block to the page where the collection should go.
  2. Paste the §9 snippet into it and replace the key.
  3. 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 is https://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:


10. Checklist#

  1. In WatchFlow, Settings → API → Create key as the account owner, registering your site's exact origin(s). Copy the key — it is shown once.
  2. curl -H "X-API-Key: …" https://mywatchflow.com/api/public/v1/ping — expect {"ok":true}.
  3. Confirm the items you want are switched on for Show on retail website.
  4. Paste the snippet in §9, replace the key, and restyle the markup as your own.
  5. Change something in WatchFlow and reload your page — it should be there.
  6. 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.