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
| Field | Required | Notes |
|---|---|---|
source | yes | Store name, e.g. "Real Deals Ankeny". Created automatically the first time it's seen. source_id (numeric) works too. |
kind | first time only | franchise, competitor or vendor. Needed when a new source is created; ignored afterwards. |
title | yes | Product name as shown on the store. |
sku | sku or url | Dedupe key per store. Without it the url is used instead. |
url | sku or url | Product page. Cards link to it, and it's used to discover images when none are sent. |
price | no | Number or string; "$5.00" is fine. |
compare_at_price | no | The store's "was" price. |
sizes | no | Free text, e.g. "S to L". |
image_url / image_urls | no | Direct image link(s). Strongly recommended: skips page scraping. Vendors can send the whole gallery as image_urls. |
category | no | Shown 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, currency | no | Pass through from Shopify when available. tags may be an array or a comma-separated string. |
published_at | no | When the store listed it (Shopify published_at). Drives the New badge, "Newest listed" sort and the Listed filter. |
found_at | no | When 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_code | no | Normally derived from the SKU prefix (830-TT-MF-… → 830). Send it only when the SKU doesn't carry it. |
refresh_images | no | true 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:
image_url/image_urlsyou sent- Shopify
/products/<handle>.json(full gallery) - JSON-LD
Product.imageandog:imageon 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.
| Query | Values |
|---|---|
kind | franchise · competitor · vendor |
source | store slug or id, e.g. real-deals-ankeny |
category | category name, or none for uncategorized |
vendor | vendor code, e.g. 830, or none |
shared | 1 to return only products carried by more than one source |
q | search in title, SKU and brand |
seen | 24h · 7d · 30d · 90d (by listing date) |
since / found_since | ISO date; by listing date / by bot discovery date |
min_price / max_price | numbers |
sort | newest (store published_at, newest first, undated last; default) · found · updated · price_asc · price_desc · drop · title |
page / limit | limit 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 / 201 | Updated / created |
207 | Batch processed; check each entry in results |
400 | Validation problem; error says what (e.g. "title is required") |
401 | Missing or wrong API key |
404 | No such product or source |
500 | Server 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.