Real Deals Watch

API

Bots post products in. Other applications read them back, including stored vendor stock images. Every request needs the API key. The website itself is public.

Authentication

Send the key as a bearer token on every /api request. x-api-key works too. Missing or wrong key returns 401.

Authorization: Bearer <API_KEY>
Content-Type: application/json

Key values with $ or ! get mangled by shells; wrap them in single quotes on the command line, or keep the key in a config file.

Post products

POST https://watch.real.deals/api/products

Send one product object, or an array of up to 500. Posting the same source + sku again updates the existing product: last-seen is bumped, and a changed price goes into the price history and shows as a Price drop when it went down. Resending everything the bot sees on each run is the intended pattern.

{
  "source": "Real Deals Ankeny",
  "kind": "franchise",
  "title": "Winter Whimsy Tea Towel",
  "sku": "TT-100",
  "sizes": "one size",
  "price": 8.00,
  "url": "https://store.example.com/products/winter-whimsy-tea-towel",
  "image_url": "https://cdn.shopify.com/s/files/.../towel.jpg",
  "category": "Kitchen",
  "published_at": "2026-10-01T18:05:11-05:00",
  "found_at": "2026-10-04T09:15:00Z"
}
curl example
curl -X POST 'https://watch.real.deals/api/products' \
  -H 'Authorization: Bearer YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"source":"Real Deals Ankeny","kind":"franchise","title":"Winter Whimsy Tea Towel","sku":"TT-100","price":8,"url":"https://store.example.com/products/winter-whimsy-tea-towel","image_url":"https://cdn.shopify.com/s/files/.../towel.jpg","published_at":"2026-10-01T18:05:11-05:00"}'

A single object returns 201 (created) or 200 (updated):

{ "ok": true, "id": 42, "created": true, "price_changed": false, "category": "Kitchen", "source": "Real Deals Ankeny" }

An array returns 207 with one result per item, so one bad row doesn't fail the batch:

{ "results": [ { "ok": true, "id": 42, ... }, { "ok": false, "error": "either sku or url is required", "sku": null, "title": "..." } ] }

Fields

FieldRequiredNotes
sourceyesStore name, e.g. "Real Deals Ankeny". Created automatically the first time it's seen. source_id (numeric) works too.
kindfirst time onlyfranchise, competitor or vendor. Needed when a new source is created; ignored afterwards.
titleyesProduct name as shown on the store.
skusku or urlDedupe key per store. Without it the url is used instead.
urlsku or urlProduct page. Cards link to it, and it's used to discover images when none are sent.
pricenoNumber or string; "$5.00" is fine.
compare_at_pricenoThe store's "was" price.
sizesnoFree text, e.g. "S to L".
image_url / image_urlsnoDirect image link(s). Strongly recommended: skips page scraping. Vendors can send the whole gallery as image_urls.
categorynoShown as a filter chip. If omitted it's inferred from product_type, tags, then the title. Unmatched products show as Uncategorized.
product_type, tags, vendor, description, currencynoPass through from Shopify when available. tags may be an array or a comma-separated string.
published_atnoWhen the store listed it (Shopify published_at). Drives the New badge, "Newest listed" sort and the Listed filter.
found_atnoWhen the bot found it. ISO 8601 or unix seconds/ms. Defaults to the time of the request. Sets first-seen on new products, extends last-seen on repeats.
vendor_codenoNormally derived from the SKU prefix (830-TT-MF-… → 830). Send it only when the SKU doesn't carry it.
refresh_imagesnotrue to re-download images on an update.

Images

Images are fetched in the background after the product is saved, so the POST returns immediately. Discovery order:

  1. image_url / image_urls you sent
  2. Shopify /products/<handle>.json (full gallery)
  3. JSON-LD Product.image and og:image on the product page

Vendor products keep the whole gallery (up to 12 images); franchise and competitor products keep the first. Files are stored under {kind}/{store}/{sku}/ and served from a public CDN domain.

GET https://watch.real.deals/api/products/:id/images — just the stored image URLs, for pulling vendor stock photos into another application.

{ "id": 42, "sku": "JB-82414", "title": "...", "status": "done", "error": null,
  "images": [ { "id": 1, "url": "https://pub-....r2.dev/vendor/judy-blue/jb-82414/0-....jpg", "position": 0, "source_url": "..." } ] }

status is pending, done or failed; error says why when it failed.

POST https://watch.real.deals/api/products/:id/refresh-images — re-queue the fetch.

Update a product

PATCH https://watch.real.deals/api/products/:id
PATCH https://watch.real.deals/api/products?source=real-deals-ankeny&sku=TT-100

Partial update when you only have the delta. The second form addresses the product by store (slug, name or id) and SKU. Body may include any of: price, compare_at_price, title, category, sizes, url, vendor, product_type, tags, description, currency, published_at, found_at, image_url / image_urls, refresh_images.

PATCH https://watch.real.deals/api/products?source=real-deals-ankeny&sku=TT-100
{ "price": 6.00, "found_at": "2026-10-05T08:00:00Z" }

→ { "ok": true, "id": 42, "price_changed": true, "product": { ... } }

A changed price is recorded in price history. Unknown source + SKU returns 404. An empty body returns 400 with the list of allowed fields.

DELETE https://watch.real.deals/api/products/:id — remove a product and its stored images.

Read products

GET https://watch.real.deals/api/products — same filters as the website. Each item includes its images.

QueryValues
kindfranchise · competitor · vendor
sourcestore slug or id, e.g. real-deals-ankeny
categorycategory name, or none for uncategorized
vendorvendor code, e.g. 830, or none
shared1 to return only products carried by more than one source
qsearch in title, SKU and brand
seen24h · 7d · 30d · 90d (by listing date)
since / found_sinceISO date; by listing date / by bot discovery date
min_price / max_pricenumbers
sortnewest (store published_at, newest first, undated last; default) · found · updated · price_asc · price_desc · drop · title
page / limitlimit up to 500, default 60
GET https://watch.real.deals/api/products?kind=vendor&seen=7d&limit=100

→ { "items": [ ... ], "total": 134, "page": 1, "limit": 100, "pages": 2 }

GET https://watch.real.deals/api/products/:id — one product with images and price_history.

Linked products

Products with the same SKU at different sources are treated as the same item. Every product in a response carries also_at: the other stores (franchise, competitor or vendor) that list that SKU, with their current price and link. Tiles show them as chips under the SKU; a price lower than this store's is highlighted.

"also_at": [
  { "id": 318, "source_name": "Real Deals Bountiful", "source_slug": "real-deals-bountiful", "kind": "franchise", "price": "19.99", "url": "...", "published_at": "...", "last_seen": "..." },
  { "id": 77,  "source_name": "Hazel & Olive Wholesale", "source_slug": "hazel-olive-wholesale", "kind": "vendor", "price": "9.50", "url": "...", ... }
]

Matching is by SKU only (case and whitespace ignored). Stores that use different SKUs for the same item won't link; send the same sku from every store to get the link.

Vendors

The prefix of a SKU is the vendor code: 830-TT-MF-winter-whimsy belongs to vendor 830. Products carry the code automatically; the vendors table maps codes to names so tiles read "Hazel & Olive" instead of "Vendor 830". Unmapped codes still show up, as Vendor 830, so you can see what needs a name.

PUT https://watch.real.deals/api/vendors/:code — create or replace one vendor.

PUT https://watch.real.deals/api/vendors/830
{ "name": "Hazel & Olive", "website": "hazelandolive.com", "notes": "min order $150", "source": "hazel-olive-wholesale" }

→ 201 { "ok": true, "created": true, "vendor": { "code": "830", "name": "Hazel & Olive", ... } }

source (slug or name) or source_id optionally links the code to a store on the Vendors tab, so the product page links to that vendor's listings.

PUT https://watch.real.deals/api/vendors — bulk: an array of { "code", "name", ... }. Returns 207 with one result per item.

GET https://watch.real.deals/api/vendors — all vendors with product counts.
GET https://watch.real.deals/api/vendors/:code — one vendor.
DELETE https://watch.real.deals/api/vendors/:code — remove the mapping (products keep their code).

Filter products by vendor with GET /api/products?vendor=830 (vendor=none for products without a code).

Sources

Sources are the stores and sites being watched. They're created on the fly by the first product post, so these are mostly for housekeeping.

  • GET /api/sources
  • POST /api/sources { "name", "kind", "domain" }
  • PATCH /api/sources/:id { "name", "kind", "domain" }
  • DELETE /api/sources/:id — also deletes its products

Responses & errors

200 / 201Updated / created
207Batch processed; check each entry in results
400Validation problem; error says what (e.g. "title is required")
401Missing or wrong API key
404No such product or source
500Server problem; the deploy logs have the detail

GET https://watch.real.deals/health — no auth. Returns { "ok": true, "r2": "ok" } when the app and image storage are both healthy.