Event tracking
How it fits together
- Your storefront searches with a public key. The answer carries a
searchId. - The storefront keeps that
searchIdwith the results it renders. - When the shopper clicks a result, adds it to the cart or buys it, the storefront posts an event with the
searchIdof the search that showed the product. - Zaproo ties the event to the search and the reports fill in: click-through, add-to-cart and conversion rates, revenue per query, top products and click positions.
Events are accepted at once and tied to their search a few seconds later, off the request path. Nothing in this flow slows the search box down.
The cabinet has no settings for events: your storefront sends them with the API, and the Analytics page shows the result. Until the first event arrives, that page shows No clicks, carts or purchases yet. You need a public API key whose allowed origins list your shop's domain, and the key of the search profile your storefront searches with.
The endpoint
POST/v1/events/:profileKeyPublic key from an allowed origin, or a secret keyprofileKey is the key of the search profile the storefront searched with. The route takes the same keys, the same origin check and the same rate limit as POST /v1/search/:profileKey. The body is a bare JSON array of 1 to 100 events. The answer is 202 { "accepted": n }, where n counts the events in the request (a purchase with three lines is one).
curl -X POST https://api.search.zaproo.cloud/v1/events/$PROFILE_KEY \
-H "Authorization: Bearer $ZS_PUBLIC_KEY" -H "Origin: https://shop.example.com" -H "Content-Type: application/json" \
-d '[{"type": "click", "searchId": "8f2Kq9xWv1pLmN3cT6yZ0a", "itemId": "tape-48", "position": 2}]'
Event types
Every event may carry sessionId, the same value the search request sends: 1 to 100 letters, digits, - or _.
itemId (required), searchId, position (1 and up, the place of the result on the page).itemId (required), searchId, quantity, price (one unit) and currency (ISO 4217, EUR).orderId (required), currency (required) and items: 1 to 100 lines of itemId, quantity, price and an optional searchId per line. Send every order, not only the ones that came from search: the share of revenue compares the two.suggestion (the text, required) and itemId when the suggestion was a product.[
{ "type": "click", "searchId": "8f2Kq9xWv1pLmN3cT6yZ0a", "sessionId": "s_41f", "itemId": "tape-48", "position": 2 },
{ "type": "add_to_cart", "searchId": "8f2Kq9xWv1pLmN3cT6yZ0a", "sessionId": "s_41f", "itemId": "tape-48", "quantity": 1, "price": 1.86, "currency": "EUR" },
{ "type": "purchase", "sessionId": "s_41f", "orderId": "100042", "currency": "EUR",
"items": [{ "itemId": "tape-48", "quantity": 1, "price": 1.86, "searchId": "8f2Kq9xWv1pLmN3cT6yZ0a" }, { "itemId": "box-300", "quantity": 2, "price": 0.32 }] },
{ "type": "suggestion_click", "sessionId": "s_41f", "suggestion": "duct tape" }
]
searchId and the session fallback
searchId is the 22-character id in every search response. It is null when the search is not logged (an inline profile, the editor's preview), and such a search cannot be tied to events.
An event without a searchId but with a sessionId is tied to the latest live search of that session within the last 24 hours. That covers the cart and the checkout, where the search id is long gone but the session is not. An event with neither is stored unattributed: it counts in the share of revenue, but in no per-query number.
Revenue of a purchase line goes to the search its searchId names, or to the session's last search. Lines from a search get quantity × price added to that query's revenue.
A script for your storefront
zaprooSearch.track({ type: 'click', searchId, itemId, position }). Use the script below if you'd rather not add a package.The snippet below keeps the last searchId per product and sends events with fetch and keepalive: true, which lets the request finish while the page unloads. navigator.sendBeacon cannot send the Authorization header this route needs, so do not use it here.
// zaproo-events.js: searches with a public key and reports what shoppers do next.
const API = 'https://api.search.zaproo.cloud';
const KEY = 'zs_pk_YOUR_PUBLIC_KEY';
const PROFILE = 'YOUR_PROFILE_KEY';
const sessionId = sessionStorage.zsSession ??= Math.random().toString(36).slice(2, 12);
const searchOf = new Map(); // itemId -> searchId of the search that showed it
const headers = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' };
export async function search(body) {
const res = await fetch(`${API}/v1/search/${PROFILE}`, { method: 'POST', headers, body: JSON.stringify({ ...body, sessionId }) });
const data = await res.json();
if (data.searchId) for (const hit of data.items) searchOf.set(hit.id, data.searchId);
return data;
}
export function track(events) {
const body = JSON.stringify(events.map((e) => ({ sessionId, ...e })));
return fetch(`${API}/v1/events/${PROFILE}`, { method: 'POST', headers, body, keepalive: true }).catch(() => {});
}
// Call these from your result list, product page and checkout.
export const click = (itemId, position) => track([{ type: 'click', itemId, position, searchId: searchOf.get(itemId) }]);
export const addToCart = (itemId, quantity, price, currency) => track([{ type: 'add_to_cart', itemId, quantity, price, currency, searchId: searchOf.get(itemId) }]);
export const purchase = (orderId, currency, items) =>
track([{ type: 'purchase', orderId, currency, items: items.map((i) => ({ ...i, searchId: searchOf.get(i.itemId) })) }]);
export const suggestionClick = (suggestion, itemId) => track([{ type: 'suggestion_click', suggestion, itemId }]);
searchOf lives in memory, so it is empty on the product page after a full navigation. Keep it in sessionStorage if your store is not a single-page app, or rely on the session fallback: the sessionId is enough for the cart and the order to find the last search.
What is stored
Each event is stored with its type, the search it was tied to, the normalized query of that search, the item, position, quantity, price, revenue and currency as sent, the order id, the salted session hash, the device class and country, and the traffic source of the request. Events from an excluded IP address or a crawler are kept but left out of the reports. Events follow the same retention as searches.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_FAILED | A field is missing or wrong; details names it. The body must be an array of 1 to 100 events. |
| 401 | UNAUTHENTICATED | No key, or an unknown, revoked or expired one. |
| 403 | ORIGIN_NOT_ALLOWED | A public key from an origin that is not on its list. |
| 404 | PROFILE_NOT_FOUND | No saved profile with that key in the key's project. |
| 429 | RATE_LIMITED | The search rate limit; see Rate limits. |
Common mistakes
- Sending the search id of the wrong search. A product can show up for several searches. Keep the id of the search that showed the result the shopper clicked.
- Leaving out the session id. Without it, a purchase without
searchIdper line cannot be tied to anything. - Prices per line, not per order.
priceis one unit; revenue isquantity × price. - Tracking from a server with a public key. A server sends no
Originheader, so the public key is refused. Use a secret key from a server.
Related
Reports
Every analytics report with the definition of each column, the opportunities list and its fix buttons, the per-query view with before and after, and the CSV download.
MCP setup
Connect Claude Code or another MCP client to your project from the cabinet's MCP connection page, and let an agent manage indices, documents, profiles, synonyms and rules.