Track events
/v1/eventsRecords a batch of shopper-side analytics events against an analytics session. This is the endpoint the Stylor widget uses; you can call it from your own storefront code with the same session.
Authentication
Authentication is different here. This endpoint does not take an
API key and is not metered against your plan's quota or
requests-per-minute limits, so none of the shared key, quota or
rate-limit errors apply. Instead it is authenticated by the signed
session token that the widget preflight call hands out
(session.token in that response). Send it in the X-Stylor-Session
header. If you cannot set headers — navigator.sendBeacon on page
unload, for instance — put it in the body as sessionToken; the header
wins when both are present. Tokens are valid for two hours; run
preflight again to get a fresh one.
The organization an event belongs to is read from the token's
signature, never from the request, so events cannot land in another
tenant's data. If the token carries a storefront origin and the request
carries an Origin header, the two must match (requests from Stylor's
own widget frame are also accepted).
Batches
events holds up to 50 events. An empty array is accepted and returns
accepted: 0. Each event is judged on its own: a bad row is reported in
rejected with its index and a reason, and the rest of the batch is
still stored. Only the batch-level checks — shape, size, session and
rate limit — fail the whole request.
Every event needs a client-generated eventId. The same id seen again
within ten minutes is rejected as duplicate, which makes it safe to
retry a failed request with the same ids. Delivery is at-least-once
beyond that window.
Which events a client may send
Only the events listed under name are accepted from a client. Each
has its own data schema, described in the EventData… schemas below;
unknown keys inside data are dropped, and a missing or wrong-typed
field rejects the event with invalid_payload:<field>. The two cart
events additionally require a single-use intent token issued by the
stylist when it resolved the product — the product, size and price are
read from that token, and the client reports only the outcome.
Server-side events such as search.performed or turn.completed exist
in the same stream but are rejected from clients with tier_forbidden.
Rate limits
Independent of your plan: 200 events per session per minute and 5,000
per organization per minute. Exceeding either returns 429 for the
whole batch.
Timestamps
occurredAt is taken as sent unless it is unparseable or more than five
minutes ahead of the server clock, in which case the server's receive
time is used.
CORS
The route answers OPTIONS preflight with 204 and allows any
origin, so it can be called directly from a storefront page.
Headers
2The session token from widget preflight. Required unless sessionToken is sent in the body.
Set automatically by browsers. When present and the session token carries a storefront origin, the two must match.
Request body
application/jsonrequiredThe batch. Empty is allowed; at most 50 entries.
Client-generated unique id (a UUID is conventional). Repeats within ten minutes are rejected as duplicate, so reuse the same id when retrying.
The event type. Determines the required shape of data. Names not in this list are rejected with unknown_event; server-only names are rejected with tier_forbidden.
widget.loadedpage.viewedwidget.openedwidget.closedmessage.sentproduct.clickedsuggestion.clickedconversation.clearedquestion.answeredcart.item_addedcart.size_changedcart.add_observedThe event payload. Defaults to {}. Must match the schema for name (see EventData… below); unknown keys are dropped.
When the event happened on the client. Falls back to the server's receive time when unparseable or more than five minutes in the future.
Monotonic per-session counter for ordering events that share a timestamp. Non-numeric values are stored as 0.
Required for cart.item_added and cart.size_changed. The single-use intent token issued by the stylist for this action. Must belong to this session and this event name, be unexpired (five minutes), and not have been used before.
The conversation the event belongs to, if any.
The storefront page the shopper was on.
Client build information, for correlating behaviour with releases.
Links a shopper action back to the surface that presented the product. For the two intent-anchored cart events this block is overwritten by the intent's own attribution when it has one.
Per-visit metadata sent once per batch rather than on every event. Stored on each accepted event.
Where the events come from. Anything other than the listed values is recorded as widget.
widgetplaygroundThe consent-driven storage level the client is running at. Only full allows visitorId to be linked to the session. Not validated here; the widget uses full, continuity or memory.
Version of the client emitting the events.
IANA time zone of the shopper.
BCP 47 locale of the shopper.
Device class, free text.
A durable, client-held visitor id. Only used when storageMode is full; it is hashed with the organization id before storage and links this session to a returning visitor for up to 13 months.
Fallback for the X-Stylor-Session header when headers cannot be set (for example sendBeacon). Ignored when the header is present.
Response
rejected for rows that were not stored.Events stored.
Events that were not stored, with their position in events.
0-based index into the submitted events array.
Why the event was dropped. One of
malformed (not an object),
missing_event_id,
unknown_event (name not registered),
tier_forbidden (a server-only event name),
duplicate (same eventId seen in the last ten minutes),
invalid_payload:<field> (data failed validation; the first failing field is named),
or, for the intent-anchored cart events,
intent_missing, intent_malformed, intent_signature,
intent_expired, intent_session_mismatch,
intent_event_mismatch, intent_replay (already used) or
intent_unconfigured.
Errors
7Every error is JSON with a single error string unless noted. Match on the status code; the message is for humans.
Request body must be valid JSON.The body could not be parsed as JSON.
Request body must be a JSON object.The body is empty, or is valid JSON that is not an object (for example an array).
Invalid analytics session.The session token is missing, malformed, has a bad signature, has expired, or was minted for a different storefront origin than the request's Origin. The body also carries reason: one of missing, malformed, signature, expired, origin, or unconfigured (server-side misconfiguration).
{
"error": "Invalid analytics session.",
"reason": "expired"
}'events' must be an array.events is missing or not an array.
A batch may contain at most 50 events.events has more than 50 entries.
Event rate limit exceeded.More than 200 events in the last minute for this session, or more than 5,000 for the organization. No Retry-After header; wait up to a minute.
Internal server error.An unexpected failure on the server. The message is always this string; retry the batch later.
curl -X POST "https://stylor.ai/api/v1/events" \
-H "Content-Type: application/json" \
-d '{
"session": {
"surface": "widget",
"storageMode": "full",
"agentVersion": "2.3.1",
"tz": "America/Toronto",
"locale": "en-CA",
"device": "mobile",
"visitorId": "4d2a9c1e-7b3f-4e8a-9c6d-1f2e3a4b5c6d"
},
"events": [
{
"eventId": "0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"name": "widget.opened",
"occurredAt": "2026-09-12T14:03:11.412Z",
"seq": 4,
"page": {
"path": "/products/linen-camp-collar-shirt",
"pageType": "product",
"productId": "8841234567890"
},
"data": {
"trigger": "launcher",
"msSincePageLoad": 8120
}
},
{
"eventId": "1d2e3f4a-5b6c-4d7e-9f8a-0b1c2d3e4f5a",
"name": "message.sent",
"occurredAt": "2026-09-12T14:03:29.907Z",
"seq": 5,
"chat": {
"chatId": "8b4d3f6e-2c1a-4f0e-9d7b-5a6c1e2f3a4b",
"turn": 1
},
"data": {
"inputType": "text",
"charCount": 42,
"imageCount": 0
}
}
]
}'{
"accepted": 2,
"rejected": [
{
"index": 1,
"reason": "invalid_payload:charCount"
}
]
}