# API Reference

Interactive Swagger docs: <https://api.basketbooster.eu/docs>.
Base URL: `https://api.basketbooster.eu`.

Two key types, two audiences:

| Key | Sent as | Where it lives | Used for |
|---|---|---|---|
| Publishable `pk_...` | `?key=` query param or `X-Pub-Key` header | Storefront HTML/JS (public by design) | Widget API (`/v1/recommendations`, `/v1/events`, search, looks) |
| Secret (API key) | `X-API-Key` header | Your server only | Catalog push (`/v1/items`), order sync (`/v1/orders`), integration API, order-history upload |

The widget endpoints are **CORS-open and preflight-free**: parameters travel in
the query string and event posts use a CORS-safelisted content type, so browsers
call them directly with no preflight round-trip.

---

## Widget API (publishable key)

### GET /v1/recommendations

Recommendations for a product page or cart.

| Param | Meaning |
|---|---|
| `key` | Your `pk_` key (or send `X-Pub-Key`). |
| `item_id` | Anchor product (product page). |
| `item_ids` | Comma-separated basket ids (cart page — the engine completes the set). Up to 50 anchors. |
| `user_id` / `customer_id` | Optional shopper identity for personalised results. |
| `num` | Result count, 1–20 (default: your dashboard setting). |
| `source` | `mix` (default), `buy` (frequently-bought-together only), `browse` (personal history), `similar` (visually similar by product photo; requires `item_id`). |

```bash
curl "https://api.basketbooster.eu/v1/recommendations?key=pk_YOUR_KEY&item_id=123&num=8"
```

```json
{"recommendations": [
  {"id": "456", "title": "…", "price": 19.9, "image_url": "…", "product_url": "…", "score": 0.83}
]}
```

Results respect your merchandising rules (boost/exclude), scheduled
availability, and the hide-out-of-stock setting. `source=buy` returns only
genuinely co-purchased items and is never padded with fallbacks.

`source=similar` matches products by their photos (image embeddings), needs no
behavioural data, and backfills like `mix` when the anchor has no indexed
image. Short `mix`/`browse` results are also topped up with visually similar
items before the generic category fill. Both behaviours can be turned off with
the "visual similarity" toggle on the dashboard Merchandising page.

### GET /v1/trending

Non-personalised trending/bestseller feed. Params: `key`, `num`, and optional
`user_id` (the widget session id — used only for A/B holdout assignment when
the merchant runs an incrementality test; the list itself stays
non-personalised). Same response shape as `/v1/recommendations`.

How items are ranked is configurable on the dashboard Merchandising page
("Trending widget ranking"): by order frequency (default), units sold, or
revenue, over a merchant-set look-back window (default 30 days). An optional
minimum price hides low-value items (e.g. packaging) from the feed; changes
apply within minutes.

### POST /v1/events

Behavioral events that train the engines. The widget automatically sends
`detail-page-view`, `rec-impression`, and `rec-click`; your storefront must send
`add-to-cart` (from your cart handler) and `buy` (from checkout) itself — the
widgets can't see your cart button or your checkout. Use `ProductRecs.track`
(see [widget-embed](widget-embed.md)) or post directly. The body is JSON,
posted with `Content-Type: text/plain` (or `sendBeacon`) to stay preflight-free.

```bash
curl -X POST "https://api.basketbooster.eu/v1/events?key=pk_YOUR_KEY" \
  -H "Content-Type: text/plain" \
  -d '{"event": "buy", "item_id": "123", "order_id": "ORDER_1", "user_id": "cust_9", "value": 49.9, "currency": "EUR"}'
```

| Field | Meaning |
|---|---|
| `event` | **Required.** `detail-page-view`, `add-to-cart`, `buy`, `category-view`, `search-pref`, `search-click`, `image-search-click`, `metadata-pref`, `rec-impression`, `rec-click`. |
| `item_id` | Product the event refers to. |
| `user_id` / `session_id` | Shopper identity; anonymous sessions are fine. `user_id` wins when both are sent — keep it consistent with the widget embeds (`data-user-id`), or attribution can't join events (see [widget-embed](widget-embed.md)). |
| `order_id` | On `buy`: groups items purchased together (feeds the FBT model). |
| `value`, `currency` | On `buy` / `add-to-cart`: order-**line** revenue (unit price × units) for attribution reporting. |
| `quantity` | On `buy` / `add-to-cart`: **units in the line**. Send it — unit-level demand forecasting, replenishment and overstock reports only work when quantities are known; without it the forecaster falls back to counting order lines. Never fabricated server-side: absent means unknown. |
| `item_ids` | On `rec-impression`: all shown product ids (array or comma string). |
| `sources`, `positions` | On `rec-impression`: arrays index-aligned with `item_ids`, echoing each shown item's `source` (from the recommendations response) and 1-based display position — powers the per-source effectiveness report. On `rec-click`, send scalar `source` and `position`. |
| `search_query` | On `search-pref` (trains the engine) and `search-click` (a click on a search result — feeds the Search Insights click-through numbers; sent automatically by the search widget). Image searches have no query text — a click on an image-search result is `image-search-click` with no `search_query` (also sent automatically), counted in the Insights image-search totals. |
| `widget` | Which widget produced it (`recs`, `fbt`, `cart`, `trending`, `search`) for the engagement breakdown. |

Events are **never rate-degraded or counted against your quota** — data
continuity is preserved even over quota.

> Syncing purchases from your backend instead of the browser? `POST /v1/orders`
> (below) takes the whole order in one call.

### GET /v1/search

Typo-tolerant keyword search. Params: `key`, `q`, `num`, `in_stock=true|false`.
Returns `{"results": [...]}` with the same product shape as recommendations.

### POST /v1/search/image

Search by photo: post `{"image": "<base64>", "num": 12, "in_stock": true}` as
`text/plain`. A `data:` URL prefix is tolerated. Photos are analyzed on the fly
and never stored.

### GET /v1/looks?item_id=…

The most recent **published** Shop-the-Look for a product, or `{"look": null}`.

### POST /v1/shop-the-look/analyze

On-the-fly Shop-the-Look: post `{"image": "<base64>"}` (text/plain); returns
detected products as hotspots `{"hotspots": [{label, bbox, confidence, products}]}`.
Nothing is persisted.

### GET /v1/widget-config?type=recs

The resolved appearance config for a widget type (`recs`, `search`, …) — used
by the widget JS; also handy for headless frontends that render their own UI.

### Quota headers

Content endpoints are metered. When your account approaches or passes its plan
quota the responses carry headers instead of failing:

- `X-Quota-Used` / `X-Quota-Limit` — present from 80% usage.
- `X-Billing-Status: warn | over_quota | suspended` — on `over_quota`/
  `suspended` the endpoint returns **HTTP 200 with an empty payload** so your
  storefront layout never breaks. See [plans-and-billing](plans-and-billing.md).

---

## Server API (secret key, `X-API-Key` header)

### POST /v1/items

Bulk-upsert catalog items (create or update), max **1000 per request**. Pushed
items sync to the search index and recommendation engines automatically.

```bash
curl -X POST "https://api.basketbooster.eu/v1/items" \
  -H "X-API-Key: YOUR_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{"items": [{
    "id": "123",
    "sku": "SHOE-TRAIL-42",
    "title": "Trail Running Shoe",
    "product_url": "https://your-store.com/p/123",
    "image_url": "https://your-store.com/img/123.jpg",
    "price": 89.9,
    "availability": "in_stock",
    "stock_qty": 42,
    "categories": ["Shoes", "Running"],
    "description": "…",
    "available_from": "2026-08-01",
    "available_until": "2026-09-30"
  }]}'
```

`id` is the key every event, recommendation and forecast joins on. `sku` is
optional and display-only — your own product code, shown alongside the id in the
Demand stockout alerts and product table. `stock_qty` (units in stock) drives
those alerts; with no `availability` field, a `stock_qty` of 0 counts as out of
stock. `available_from` / `available_until` bound the window in which an item may
be recommended or forecast.

Only genuinely **new** ids count toward your plan's catalog limit — updating
existing items always works, even at the limit.

### DELETE /v1/items/{item_id}

Remove one item from the catalog and the engines. Returns 404 if unknown.

### POST /v1/orders

Sync **one completed order** in a single call — fire it from your order webhook
(order created / payment complete) so the recommender learns from every sale in
real time. Each line item becomes a `buy` signal for the engine and the
Frequently-Bought-Together model, plus revenue attribution for the dashboard.

Accepts the secret key (`X-API-Key`, recommended — webhooks run on your server)
**or** the publishable key (`?key=` / `X-Pub-Key`) for checkout-page JS. Body is
regular `application/json`.

```bash
curl -X POST "https://api.basketbooster.eu/v1/orders" \
  -H "X-API-Key: YOUR_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{
    "order_id": "ORDER_1234",
    "user_id": "cust_9",
    "currency": "EUR",
    "items": [
      {"item_id": "123", "quantity": 2, "price": 19.9},
      {"item_id": "456", "quantity": 1, "total": 49.9}
    ]
  }'
```

| Field | Meaning |
|---|---|
| `order_id` | **Required.** Your store's order id — groups the basket (FBT signal) and deduplicates retries. |
| `items[]` | **Required, 1–100 lines.** Each: `item_id` (required, matches your catalog ids), `quantity` (default 1), `price` (unit price), `total` (line revenue; defaults to `price × quantity`). |
| `user_id` | Customer id, if known — personalises future recommendations. Must be the **same id the widgets see** (`data-user-id`), or attribution can't credit the sale. |
| `session_id` | The widget's session id — links the order to the browse session for attribution. Mirror `localStorage["pr_sid"]` into a first-party cookie and read it in your webhook (see [widget-embed](widget-embed.md)). Ignored for the attribution join when `user_id` is also present. |
| `currency` | Revenue currency for attribution reporting. |

```json
{"ok": true, "order_id": "ORDER_1234", "items_ingested": 2, "duplicate": false}
```

**Idempotent per `order_id`**: webhook redeliveries within 7 days return
`"duplicate": true` and ingest nothing, so retrying is always safe. Like
`/v1/events`, this endpoint is never metered or billing-degraded.

> **Don't fire-and-forget over raw sockets.** Writing the request and closing
> the TLS connection without reading the response aborts the request before the
> server processes it — nothing arrives. If you keep checkout latency low with a
> raw socket, read at least the response status line before closing; otherwise
> use a normal HTTP client with a short timeout (the endpoint answers in well
> under a second).

Equivalent alternatives: one `/v1/events` `buy` per line item (what the widget
and the WooCommerce plugin send), or `/orders/upload` for bulk history.

### POST /orders/upload

Multipart CSV upload of historical orders to seed the Frequently-Bought-Together
model, demand forecasting and customer intelligence. Header row required:
`order_id,item_id[,customer_id][,timestamp][,quantity][,price]` — one row per
order line; `order_id` groups items bought together. Returns
`202 {"upload_id": …, "status": "queued"}`; import runs in the background.

Column notes (synonyms accepted: `qty`/`units` for quantity, `unit_price`/
`item_price` for price):

- `timestamp` — ISO format (`2026-08-01` or a full datetime). **Unparseable or
  missing timestamps collapse the whole history onto the import date**, which
  wrecks the demand forecast — always send real order dates.
- `quantity` + `price` — units per line and unit price. Line revenue is stored
  as `price × quantity`. Without quantities the forecaster counts order lines
  instead of units.
- `customer_id` — enables customer intelligence (RFM/churn/CLV); rows without
  it still teach co-purchase but are never profiled.

**Re-imports repair, never duplicate**: lines are deduplicated per
`(order_id, item_id)`, and a re-upload that carries quantity, price or a
parseable timestamp **overwrites** those stored values (your export is
authoritative) — so fixing a bad first import is just importing the corrected
file again. Absent columns keep the stored values.

### Integration API (`/integration/...`)

Used by store plugins (this is what the WooCommerce plugin calls) and available
to custom integrations:

| Endpoint | Purpose |
|---|---|
| `POST /integration/connect` | Plugin self-identification (`{platform, plugin_version, store_url}`) — drives the dashboard setup checklist. Idempotent. |
| `GET/PUT /integration/widget-config` | Read/merge widget appearance settings (e.g. `layout`). |
| `GET/PUT /integration/recommender-settings` | Merchant recommendation knobs. |
| `POST /integration/engines/init` | Provision recommendation engines (idempotent). |
| `POST /integration/sync-engines` | Queue a full catalog push to the engines (202). |
| `POST /integration/train` | Trigger training manually (after a bulk import). |
| `GET /integration/status` | Compact status: catalog items, last sync, last trained. |

### Errors worth handling

- `401` — missing/invalid key.
- `402 {"code": "billing_inactive" | "plan_limit_catalog"}` — no active
  plan/trial, or the push would exceed the plan's catalog limit.
- `400` — validation (e.g. >1000 items, missing CSV columns).
