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-10-25 (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), or Show on wholesale for a wholesale key. Everything else in your book is invisible here. Unpublish an item and it is gone from the API immediately, photos included. The one exception is a key you set to share all in stock (§8a): it serves every eligible item on its channel without the per-item switches.

It never returns sold pieces. Only In Stock pieces, plus On Memo and Reserved if you leave those on for the key (§7a). Sold and Returned to Vendor never appear, under any parameter.

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 → Connections → Website feed. Click Create key and choose Retail (or Wholesale). The key is shown once, on screen, with a Copy button. Next to it is what the person building your site needs: the watchflow-connect.md setup file for an AI builder, or the docs link for a developer. Neither ever has the key inside; send the key separately. Every key is server-only: it lives in your site's server settings, never in a page.

Building your site with an AI builder (ChatGPT, Claude, Lovable and the like)? Download watchflow-connect.md for the key (§2.2) and give the AI that file, never the key. Chats are saved; a key pasted into one is a leaked key.

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" }

Only the account owner (or a team co-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. Keys no longer register website addresses (allowed origins). Sending a non-empty allowedOrigins (or allowed_origins) is refused with 400 ORIGINS_NOT_SUPPORTED; an empty list is accepted and ignored. Optional websiteUrl (or website_url, on mint or PATCH): your site's https address (just the site, e.g. https://yourshop.com, max 200 characters; null or "" clears it, http and paths answer 400 INVALID_WEBSITE_URL). It is a label only, shown in watchflow-connect.md and your Website settings; it never affects which requests a key answers.

The response contains your key once:

{
  "key": "wfk_9tK2mQ0xR7bV3nL5pA8sD1fG4hJ6kZ...",
  "apiKey": {
    "id": 12,
    "name": "Main website",
    "key_prefix": "wfk_9tK2mQ0x",
    "allowed_origins": [],
    "scopes": ["inventory:read"],
    "display_options": { "includeSold": false, "includeReserved": true, "includeOnMemo": true },
    "share_all": false,
    "include_memo": true,
    "include_reserved": true,
    "enabled": true,
    "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, settings, last used, request count). Never the secret. limits.max_origins is 0 and server_only is true.
GET /api/api-keys/:idOne key and its settings. Never the secret.
GET /api/api-keys/:id/connect.mdThe watchflow-connect.md prompt for an AI builder (§2.2). Markdown, no key inside.
POST /api/api-keysMint a key. Returns the plaintext once. Optional: share_all, include_memo, include_reserved (§8a).
PATCH /api/api-keys/:idRename, or change the key's settings — share_all, include_memo, include_reserved, enabled (§8a) — without re-deploying the key. Sending scopes is refused with 400 SCOPES_IMMUTABLE; sending origins with 400 ORIGINS_NOT_SUPPORTED (an empty list clears a key made before 2026-10-25).
DELETE /api/api-keys/:idRevoke. Takes effect on the very next request. Permanent.

Notes:

2.1 Pausing a key, and the Website card#

Every key has its own on/off switch: enabled. Turn it off (PATCH /api/api-keys/:id with {"enabled": false}) and that key — only that key — answers 403 KEY_DISABLED on the very next request, photos included. Turn it back on and the same key resumes. Nothing to re-deploy, no cache to wait out. Use one key per site or partner, so you can pause one without touching the others.

Minting a key also creates a Website card in Destinations, alongside Telegram and WhatsApp. Since 2026-10-20 that card is not an on/off switch for your keys: pausing your own website must not cut off a partner who pulls your feed with a different key, so each key is paused on its own.

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.
Set enabled: false on a keyThat key answers 403 KEY_DISABLED. Other keys are untouched.
Set enabled: true againThe same key answers again.
Disconnect / reconnect the cardNo effect on any key. The card only records that you have a website integration.
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 — including partner keys. This is the destructive option; pause individual keys instead if you only want a break.

The Website card cannot be added by hand in Destinations. It appears when you mint a key and is managed from there.

2.2 watchflow-connect.md: connecting a site with an AI builder#

GET /api/api-keys/:id/connect.md (signed in as the owner or a co-owner) returns a short Markdown prompt written for an AI website builder. It carries the base URL, the key's channel(s), your account's source label, and the placeholder WATCHFLOW_API_KEY: never the key, not even its prefix.

It tells the AI to:

Team members get 403 OWNER_ONLY, demo accounts 403 DEMO_NO_API_KEYS, a revoked key 400 KEY_REVOKED, and another account's key id 404.

Never paste your WatchFlow key into ChatGPT, Claude, Lovable or any AI chat. Chats are saved. Only paste it into the secure key box your site creates.

Revoking#

Settings → Connections → Website feed → 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 → Connections → Website feed → Create key with the same access. Copy it.
  2. Put the new key into your site's server settings (environment variable) and redeploy.
  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 feed offline while you think, pause the key (enabled: false, §2.1) — it stops 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. Rotate it anyway. A leaked wholesale key is serious, because trade pricing is not public. That is one reason every key is server-only: a key that never reaches a browser or a chat is very hard to leak.


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.
403SERVER_ONLY_KEYThe request came from a browser (it carried an Origin header). Call from your server; don't forward the browser's Origin header. (§4)
403KEY_SCOPE_CHANGEDThe account joined or left a team since the key was issued, or the person who minted it is no longer the team's owner or a co-owner. Mint a new one.
403SCOPE_REQUIREDYou asked for a channel this key does not carry (e.g. wholesale with a retail-only key).
403KEY_DISABLEDThe account owner paused this key (enabled: false, see §2.1). The key itself is fine; it resumes when switched back on.
403DEMO_ACCOUNTThe key belongs to a WatchFlow demo account. Demo accounts never serve this API.
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.

KEY_DISABLED, SUBSCRIPTION_INACTIVE and PLAN_UPGRADE_REQUIRED 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 per-key enabled switch is not cached at all.


4. Server-only: no CORS, no browser calls#

Every key, retail and wholesale, is server-only (since 2026-10-25). The API never answers a browser:

Call from your server; don't forward the browser's Origin header. A proxy or server route that copies the visitor's Origin header onto its call to WatchFlow is refused with 403 SERVER_ONLY_KEY like a browser would be.

Why: a key used from browser JavaScript is readable by anyone who views the page source. Kept in your server's environment variables, it never reaches the public. Your pages call your own server route (for example /api/watches), which adds the key and calls WatchFlow (§9).

Keys made before 2026-10-25 may still list website addresses. They are ignored: those keys are server-only too, still list, and can be revoked as usual.


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. 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
statusall, available, reserved — only narrows within the key's policy (§7a); sold matches nothingall = the key's policy
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 price this response emits on this channel; a price-on-request item never matches—
channelretail, wholesale (needs the wholesale scope); any other value is read as retailretail; wholesale for a wholesale-only key (§8)

An unrecognised sort falls back to newest rather than erroring. price_asc / price_desc order by the emitted price only; price-on-request items always come last in both directions, in a fixed order, so a sort can never reveal a price that is not published.

{
  "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",
      "price_on_request": false,
      "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,
      "source": {
        "business_name": "Meridian Time Co.",
        "code": "MT",
        "dealer_ref": "dlr_Yk3fQ2mX9pL0aZ7c"
      }
    }
  ],
  "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.

Same rules as the list: the item must be on the key's channel and inside the key's status policy. Once a piece sells (or goes back to its vendor) its id returns 404 — handle that as "no longer available" on your detail page. An id belonging to another dealer, to an item that is not published, or outside the key's policy returns the same 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 and scopes (allowed_origins is always [] and server_only is true), plus the same source object every item carries (§7). 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 that one of your live keys would serve right now — unpublish an item, sell it, or pause/revoke the key that served it, and its photos stop resolving (plain 404). Photos also stop while the subscription is lapsed, and never resolve for a demo account.


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"A two-word public vocabulary, not the internal one. "On Memo" reports as available (you still own it). Sold and "Returned to Vendor" are never served, so they have no public word.
pricenumber \nullThe price you typed for this channel. Retail: your website retail price if you set one, otherwise your asking price — the "Target retail" box. (With Two price targets switched on, only Target retail counts, because the asking price is hidden.) Wholesale: your "Target wholesale" price, otherwise the wholesale price you typed when you added the watch. Never your tag price, your cost, or the other channel's price. null when there is nothing to publish — and on retail when you have switched prices off on your website.
price_formattedstring \nullThe same number, formatted in its own currency. Convenience only. null whenever price is.
price_on_requestbooleantrue exactly when price is null — print "Price on request" / "Inquire".
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_atnullKept for compatibility. Sold items are no longer served, so it is always null.
sourceobjectInternal: for your back office, do not display to customers. Which WatchFlow account this item comes from, for a consumer that pulls several dealers' feeds: business_name (the account's business name, else the account name), code (a short uppercase code, by default the business-name initials), dealer_ref (a stable, opaque id for the account — never an internal WatchFlow id; created with the account's first key, and null only for an older key until its owner next opens Settings → Connections → Website feed). The same object comes back on /ping.

Never returned, under any parameter#

cost · unit_cost · total_cost · purchase_expense · purchase_expenses · tag_price · target_wire_price · sold_price · dealer_market_price (as a column; it can be the wholesale price) · profit or margin of any kind · purchase_from · purchase_date · the inventory source / source_name columns (where a piece was bought — not the source label above) · any *_contact_id · consignment_* · traded_with_* · memo_* · vendor_* · notes · internal_notes · serial_number · storage · barcode · service_history · warranty_expiry · acquisition_type · any price column by name (each channel's own price comes out only as price; a wholesale response never carries the retail or asking figure) · 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 (and price_on_request: true) on the retail channel too, and min_price / max_price match nothing. It is not a side door around that decision.


7a. What each key serves: the status policy#

Every key has a status policy, set by the dealer per key:

StatusServed?Setting
In Stockalways—
On Memoyes by defaultinclude_memo (default true)
Reservedyes by defaultinclude_reserved (default true)
Soldnever—
Returned to Vendornever—

The policy is the ceiling. ?status= can only narrow inside it:

So a site that sends ?status=reserved to a key with include_reserved: false gets nothing back, and no parameter can ever bring back a sold piece. The policy applies to the list, the detail route, the brand facets and photos alike.

Earlier versions had a "keep sold pieces on the site" option and let an explicit ?status= override the dealer's choice. Both are gone: the option is still accepted (as displayOptions.includeSold) so older clients do not error, but it is always stored and reported as false.

8. The wholesale channel (optional)#

There are three shapes of key, chosen in Settings → Connections → Website feed when you mint it:

AccessscopesDefault channel
Retail only["inventory:read"]retail
Wholesale only["inventory:wholesale"]wholesale
Retail + Wholesale["inventory:read", "inventory:wholesale"]retail

All three are server-only (§4).

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 (or every eligible item, with share_all, §8a), priced from your Target wholesale price, or, when that is empty, the wholesale price you typed when you added the watch, and nothing else: an item with neither comes back with price: null and price_on_request: true — never your retail or asking price. That is trade pricing — on your storefront it sits behind a password. So:

8a. Per-key settings (partner feeds)#

A key can also feed another business — a partner dealer or an aggregator that pulls your stock into their own back office. Mint one key per partner and set it up with these, on the key itself:

SettingDefaultWhat it does
share_allfalsetrue serves every eligible item on the key's channel(s) without the per-item Show on retail / Show on wholesale switches. The key's channel(s) do not change: a wholesale-only key still only serves wholesale.
include_memotrueServe On Memo pieces (§7a).
include_reservedtrueServe Reserved pieces (§7a).
enabledtrueThe key's own on/off switch (§2.1).
PATCH /api/api-keys/12
{ "name": "Partner feed", "share_all": true, "include_memo": false }

Each value must be a JSON true or false (anything else is 400 INVALID_SETTING). Every change is recorded in the key's audit trail with its before and after. Only the account owner can change them.

A consumer pulling several feeds can tell them apart with each item's source (§7) — which is for their back office only, not for display to customers.


9. Copy-paste: your inventory in your own markup#

Two small pieces: a server route that holds the key and calls WatchFlow, and your page, which calls your own route. The key lives only in your server's environment variable WATCHFLOW_API_KEY, never in a page, a commit or a chat. Every tag, class and style below is yours to replace. There is no WatchFlow stylesheet, no iframe, and no widget.

1. The server route (pick one)#

Next.js (App Router), also on Vercel. app/api/watches/route.js:

export const revalidate = 300; // re-poll every 5 minutes; sold pieces drop off

export async function GET() {
  const r = await fetch('https://mywatchflow.com/api/public/v1/inventory?per_page=100&sort=newest', {
    headers: { 'X-API-Key': process.env.WATCHFLOW_API_KEY },
    next: { revalidate: 300 },
  });
  if (!r.ok) return Response.json({ error: 'Inventory unavailable' }, { status: 502 });
  const { data } = await r.json();
  return Response.json(data.map(({ source, ...item }) => item)); // source is internal
}

Netlify Functions. netlify/functions/watches.mjs:

export default async () => {
  const r = await fetch('https://mywatchflow.com/api/public/v1/inventory?per_page=100', {
    headers: { 'X-API-Key': process.env.WATCHFLOW_API_KEY },
  });
  if (!r.ok) return new Response('Inventory unavailable', { status: 502 });
  const { data } = await r.json();
  return Response.json(data.map(({ source, ...item }) => item), {
    headers: { 'Cache-Control': 'public, max-age=300' },
  });
};

Express / any Node server:

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 }
  });
  const { data } = await r.json();
  res.set('Cache-Control', 'public, max-age=300').json(data.map(({ source, ...item }) => item));
});

PHP (WordPress, Laravel, plain PHP):

<?php
$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

2. Your page (calls YOUR route, no key)#

<div id="inventory" class="my-grid"></div>

<script>
(async function () {
  const grid  = document.getElementById('inventory');
  const money = (item) => item.price_formatted || 'Price on request';
  try {
    const res = await fetch('/api/watches');          // your server route, same site
    if (!res.ok) throw new Error(`inventory ${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.

For a detail page, add a second route that calls GET /inventory/:id the same way. A 404 means not found, or sold since: show your "no longer available" page. Photos (primary_photo, photos[].url) work in a plain <img>.

Sold pieces drop off. They are never returned, so re-poll (every 5 to 15 minutes is plenty) and remove any watch that is no longer in the feed.

Shopify, Wix, Squarespace, Webflow and other hosted builders#

These builders let you paste HTML, but the key must not go in it. Put the server route on a free serverless host (Vercel, Netlify or Cloudflare Workers, with WATCHFLOW_API_KEY in its environment settings), then point the §9 page snippet at that route's full address instead of /api/watches. If the route is on a different domain from your store, let it answer your store's address with its own CORS header; that is your route, not WatchFlow.

Building with an AI builder#

Download watchflow-connect.md for the key (§2.2) and give the AI that file. It walks the AI through the throwaway key box, the server route and the clean-up. Never paste the key into the chat.


10. Checklist#

  1. In WatchFlow, Settings → Connections → Website feed → Create key as the account owner. Copy the key (it is shown once) straight into your server's environment variable WATCHFLOW_API_KEY. Never into a page, a commit or a chat.
  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. Add the server route and page from §9 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, update the environment variable, redeploy.

Taking the feed down for a while is also a non-event: pause the key (enabled: false, §2.1). Turn it back on when you want it back.