Documentation Index
Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt
Use this file to discover all available pages before exploring further.
navigator.sendBeacon is the most reliable way to deliver tracking events from a browser. The request is queued by the user agent and guaranteed to be sent even if the page is unloading — which is exactly when you most want events like product_click and add_to_cart to land.
Use this guide as the starting point for any custom storefront integration. If you’re on Shopify with the Layers Storefront Pixel installed, this is already handled for you.
Prerequisites
- A Layers storefront access token. The same token used everywhere else in the Storefront API.
- A stable, anonymized
session_id for the current visit. Generate one at session start and persist it in sessionStorage.
- The
attribution_token returned by Search, Browse, or Blocks responses, if you want clicks and adds-to-cart to attribute back to the originating request.
The minimum viable beacon
const STOREFRONT_TOKEN = 'shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const ENDPOINT = `https://cl.uselayers.com/beacon?token=${encodeURIComponent(STOREFRONT_TOKEN)}`;
function track(event) {
const body = new Blob(
[JSON.stringify({ events: [event] })],
{ type: 'application/json' }
);
const ok = navigator.sendBeacon(ENDPOINT, body);
if (!ok) {
// Browser refused (queue full or payload too large) — fall back to fetch keepalive.
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [event] }),
keepalive: true,
}).catch(() => {
/* swallow — tracking is best-effort */
});
}
}
track({
event_id: crypto.randomUUID(),
event_type: 'product_view',
timestamp: new Date().toISOString(),
session_id: getSessionId(),
product_id: 7003338965178,
attribution_token: getAttributionToken(),
});
A few things to call out:
- Token in the query string.
sendBeacon cannot set headers, so the token rides on the URL. See Authentication for why this is safe.
Blob with type: 'application/json'. Without the explicit MIME type, the browser sends the payload as text/plain and the worker will reject it.
fetch with keepalive fallback. sendBeacon returns false if the user agent’s queue is full or the payload exceeds ~64 KB. fetch({ keepalive: true }) has the same unload-safety guarantees and accepts larger bodies.
Batching events
Sending one beacon per event works, but is wasteful. A small in-memory queue that flushes every 2 seconds (or on pagehide) cuts requests by 10–20× without losing data.
const STOREFRONT_TOKEN = 'shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const ENDPOINT = `https://cl.uselayers.com/beacon?token=${encodeURIComponent(STOREFRONT_TOKEN)}`;
const MAX_BATCH = 20; // worker limit is 100; 20 keeps us well under sendBeacon's 64 KB
const FLUSH_INTERVAL = 2000;
let queue = [];
let flushTimer = null;
function enqueue(event) {
queue.push(event);
if (queue.length >= MAX_BATCH) {
flush();
} else if (!flushTimer) {
flushTimer = setTimeout(flush, FLUSH_INTERVAL);
}
}
function flush() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (queue.length === 0) return;
const events = queue;
queue = [];
const body = new Blob(
[JSON.stringify({ events })],
{ type: 'application/json' }
);
const ok = navigator.sendBeacon(ENDPOINT, body);
if (!ok) {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events }),
keepalive: true,
}).catch(() => {
/* best-effort */
});
}
}
// Flush on unload so in-flight events aren't lost.
window.addEventListener('pagehide', flush);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
Listen for pagehide and the visibilitychange → hidden transition, not beforeunload. pagehide fires reliably on mobile Safari and back/forward cache restores; beforeunload does not.
Wiring it to storefront interactions
Once you have track() (or enqueue()), call it from the right places in your storefront code:
// Product detail page
document.addEventListener('DOMContentLoaded', () => {
enqueue({
event_id: crypto.randomUUID(),
event_type: 'product_view',
timestamp: new Date().toISOString(),
session_id: getSessionId(),
product_id: window.LAYERS_PRODUCT_ID,
attribution_token: getAttributionToken(),
});
});
// Clicks on product tiles in a collection or search results
document.querySelectorAll('[data-product-tile]').forEach((tile, index) => {
tile.addEventListener('click', () => {
enqueue({
event_id: crypto.randomUUID(),
event_type: 'product_click',
timestamp: new Date().toISOString(),
session_id: getSessionId(),
attribution_token: getAttributionToken(),
product_id: Number(tile.dataset.productId),
position: index + 1,
});
});
});
// Add-to-cart
document.querySelector('#add-to-cart')?.addEventListener('click', () => {
enqueue({
event_id: crypto.randomUUID(),
event_type: 'add_to_cart',
timestamp: new Date().toISOString(),
session_id: getSessionId(),
attribution_token: getAttributionToken(),
product_id: window.LAYERS_PRODUCT_ID,
variant_id: getSelectedVariantId(),
});
});
Impression tracking with IntersectionObserver
product_impression events are the highest-volume signal in the system — fire one only when a tile is actually visible.
const impressionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const tile = entry.target;
if (tile.dataset.impressed === '1') continue;
tile.dataset.impressed = '1';
enqueue({
event_id: crypto.randomUUID(),
event_type: 'product_impression',
timestamp: new Date().toISOString(),
session_id: getSessionId(),
attribution_token: getAttributionToken(),
product_id: Number(tile.dataset.productId),
position: Number(tile.dataset.position),
});
impressionObserver.unobserve(tile);
}
},
{ threshold: 0.5 } // at least 50% visible
);
document.querySelectorAll('[data-product-tile]').forEach((tile) => {
impressionObserver.observe(tile);
});
Session and attribution helpers
The two helpers used above. Keep them in a shared module.
function getSessionId() {
let id = sessionStorage.getItem('layers_session_id');
if (!id) {
id = `sess_${crypto.randomUUID()}`;
sessionStorage.setItem('layers_session_id', id);
}
return id;
}
// Store the most recent attributionToken from Search/Browse/Blocks responses.
function setAttributionToken(token) {
if (token) sessionStorage.setItem('layers_attribution_token', token);
}
function getAttributionToken() {
return sessionStorage.getItem('layers_attribution_token') || undefined;
}
Call setAttributionToken(response.attributionToken) every time you get a response from the Search, Browse, or Blocks APIs. Subsequent clicks, views, and add-to-cart events will then attribute correctly.
Common pitfalls
text/plain payload. Forgetting type: 'application/json' on the Blob causes the worker to read no JSON body and drop the batch. Always set the MIME type.
- Sending headers with
sendBeacon. You can’t. If you need the header form of the token, switch to fetch({ keepalive: true }).
- Sending more than 100 events at once. The batch limit is 100. Cap your queue at ~20–50 to stay well under both that and
sendBeacon’s ~64 KB payload limit.
- Reusing
event_id. Use a fresh ULID/UUID per event. The platform deduplicates on this field.
- Tracking on
beforeunload. Use pagehide and visibilitychange instead — they fire in mobile Safari and on back/forward cache transitions where beforeunload does not.
Next steps