> ## Documentation Index
> Fetch the complete documentation index at: https://docs.depict.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom search UI: tracking events

> The client-side events a custom search UI must send so Depict analytics, attribution and ranking keep working.

<Warning>
  **This API will be removed at the end of this year.** It is the
  current-generation storefront search API, and it is the supported and
  recommended way to build a custom search UI today — the Depict search modal
  runs on exactly these endpoints. A successor API is coming, and every team
  building on this one should plan for a migration before year end. Migration
  documentation will follow; ask your Depict contact to be notified when it is
  published.
</Warning>

Depict derives search analytics — click-through rate, zero-result queries,
revenue per query, ranking feedback — entirely from client-side events. A search
request on its own produces no engagement data. A custom UI must send these
events itself.

## POST /events

```
https://runway.depict.ai/events
```

The body is a JSON **array** of events. Send it as `Content-Type: text/plain`:
that keeps the request a CORS simple request, so the browser skips the preflight
round trip, and it works with `keepalive` when the page is unloading.

```javascript theme={null}
function sendEvents(events) {
  fetch("https://runway.depict.ai/events", {
    method: "POST",
    keepalive: true,
    headers: { "Content-Type": "text/plain" },
    body: JSON.stringify(events),
  }).catch(() => {
    // Never let a failed beacon break the UI.
  });
}
```

The response is `202` with the number of events accepted:

```json theme={null}
{ "accepted": 1 }
```

Rules:

* All events in one request must share the same `merchant_id`; a mixed batch is
  rejected with `400`.
* At most **100 events** and **256 kB** per request; larger requests are
  rejected with `413`.
* Malformed events fail the whole batch with `422`.
* Delivery is fire-and-forget. Ignore the response body in production code.

If Depict has issued your store a publishable tracking token, pass it as a query
parameter: `POST /events?depict_token=YOUR_TOKEN`. The token is safe to ship in
client-side code — it only authorises writing events for your store.

## Fields on every event

<ResponseField name="event_type" type="string" required>
  Which event this is. See the table below.
</ResponseField>

<ResponseField name="merchant_id" type="string" required>
  Your merchant id.
</ResponseField>

<ResponseField name="session_id" type="string" required>
  The same per-tab id you send on search requests.
</ResponseField>

<ResponseField name="device_id" type="string">
  The same per-browser id you send on search requests. Without it, every visit
  from a returning shopper counts as a new user.
</ResponseField>

<ResponseField name="locale" type="string">
  Storefront language, matching the search request.
</ResponseField>

<ResponseField name="country" type="string">
  Country code, matching the search request.
</ResponseField>

<ResponseField name="event_id" type="string">
  A UUID you generate per event. Used to de-duplicate, since `keepalive`
  requests can be delivered more than once. Strongly recommended.
</ResponseField>

<ResponseField name="client_timestamp" type="string">
  ISO 8601 timestamp of when the event happened in the browser. The server also
  stamps its own arrival time; sending both makes batching and unload delay
  measurable.
</ResponseField>

## Event types

Every search-related event carries `search_id`: the id of the search whose
results the shopper acted on, or `null` if the interaction did not come from a
search. Getting this right is what makes per-query analytics work.

| `event_type`                   | Additional fields                                                                                     | Send when                                                                                                                                                                              |
| ------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `product_impression`           | `search_id`, `product_id`, `position`                                                                 | A result becomes visible in the viewport, once per product per rendered result set                                                                                                     |
| `product_click`                | `search_id`, `product_id`, `query`, `position`                                                        | The shopper opens a product from the results                                                                                                                                           |
| `autocomplete_accept`          | `search_id`, `input_value`, `accepted_suggestion`                                                     | A query suggestion is chosen. `input_value` is what the shopper had typed, `accepted_suggestion` the plain text of the suggestion; `search_id` is the search that suggestion triggered |
| `empty_state_suggestion_click` | `search_id`, `suggestion`                                                                             | A starter suggestion from the empty state is chosen                                                                                                                                    |
| `follow_up_suggestion_click`   | `search_id`, `suggestion`                                                                             | A follow-up suggestion is chosen                                                                                                                                                       |
| `view_all_click`               | `search_id`                                                                                           | The shopper expands a truncated result list                                                                                                                                            |
| `user_feedback`                | `search_id`, `query`, `response_text`, `feedback_type`, `reasons`                                     | The shopper rates a result set. `feedback_type` is `thumbs_up` or `thumbs_down`; `response_text` is the answer text shown, or `""`; `reasons` is optional                              |
| `add_to_cart`                  | `product_id`, `variant_id`, `sku`, `search_id`, `query`                                               | A product is added to the cart                                                                                                                                                         |
| `purchase`                     | `transaction_id`, `currency`, `transaction_product_ids`, `quantities`, `prices`, `search_id`, `query` | An order completes                                                                                                                                                                     |
| `page_view`                    | `url`                                                                                                 | A storefront page loads                                                                                                                                                                |

`position` is the zero-based index of the product in the result list. It is what
makes click-through rate comparable across result sets — send it.

For `add_to_cart`, `product_id` must be the **main product id**, the same id
space as `product_click` and `product_impression`, or per-product funnels will
not join. Keep the variant in `variant_id` and the variant SKU in `sku`.

For `purchase`, `transaction_product_ids` are the variant SKUs, and `prices` are
per line item after discounts. `quantities`, `prices` and
`transaction_product_ids` are parallel arrays.

## Attribution

`add_to_cart` and `purchase` usually happen long after the search, often on a
different page, so the search context has to be carried across. The Depict modal
does it like this, and a custom UI should follow the same rules:

1. When the shopper **clicks a result** — not when the search runs — store the
   `search_id`, the `query`, and a timestamp in `sessionStorage`. A click is
   engagement; an abandoned search should not claim credit for a later purchase.
2. On `add_to_cart` and `purchase`, read that record back and send its
   `search_id` and `query` on the event.
3. **Expire it after 30 minutes.** A click half an hour before checkout is
   unlikely to have caused the conversion, and unbounded attribution inflates
   per-query conversion rates.
4. **Clear it after a purchase.** A later cart event should not be re-attributed
   to a search that already converted.

If no record applies, send `"search_id": null` and `"query": null` rather than
omitting the fields. That records the conversion as non-search traffic instead
of silently dropping it.

## Batching and volume

Impressions are the highest-volume event by far. Buffer them briefly and send
them as one array — the Depict modal collects impressions for 50 ms and flushes
whatever accumulated:

```javascript theme={null}
let queue = [];
let timer;

function trackImpression(event) {
  queue.push(event);
  if (timer) return;
  timer = setTimeout(() => {
    sendEvents(queue);
    queue = [];
    timer = undefined;
  }, 50);
}
```

Send other event types immediately — they are rare, and a click that is batched
can be lost when the page navigates away.

## Worked example

```javascript theme={null}
const base = {
  merchant_id: "YOUR_MERCHANT_ID",
  session_id: sessionId,
  device_id: deviceId,
  locale: "en",
  country: "US",
};

// A shopper clicks the third result of the current search.
sendEvents([
  {
    ...base,
    event_id: crypto.randomUUID(),
    client_timestamp: new Date().toISOString(),
    event_type: "product_click",
    search_id: currentSearchId,
    product_id: product.id,
    query: currentQuery,
    position: 2,
  },
]);

// Remember the search so a later purchase can be attributed to it.
sessionStorage.setItem(
  "lastSearch",
  JSON.stringify({
    search_id: currentSearchId,
    query: currentQuery,
    recorded_at: Date.now(),
  })
);
```

## Verifying

Confirm each event type reaches Depict once before you launch:

* `POST /events` returns `202` with `accepted` equal to the number of events you
  sent. `accepted: 0` for a non-empty batch means every event was dropped.
* Analytics in the Depict portal appear with a processing delay — check for
  presence first, exact counts later.
* Confirm `search_id` is populated on click, impression, add-to-cart and
  purchase events. Attribution silently disappears when it is `null`
  everywhere, and nothing about the response tells you it happened.
