Track events

post/v1/events

Records 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.

No auth

Headers

2
X-Stylor-Sessionstring

The session token from widget preflight. Required unless sessionToken is sent in the body.

Originstring

Set automatically by browsers. When present and the session token carries a storefront origin, the two must match.

Request body

application/jsonrequired
array<AnalyticsEvent>required

The batch. Empty is allowed; at most 50 entries.

maxItems: 50
eventIdstringrequired

Client-generated unique id (a UUID is conventional). Repeats within ten minutes are rejected as duplicate, so reuse the same id when retrying.

e.g. "0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
namestringrequired

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.

One ofwidget.loadedpage.viewedwidget.openedwidget.closedmessage.sentproduct.clickedsuggestion.clickedconversation.clearedquestion.answeredcart.item_addedcart.size_changedcart.add_observed
e.g. "widget.opened"
EventDataWidgetLoaded | EventDataPageViewed | EventDataWidgetOpened | EventDataWidgetClosed | EventDataMessageSent | EventDataProductClicked | EventDataSuggestionClicked | EventDataConversationCleared | EventDataQuestionAnswered | EventDataCartOutcome | EventDataCartAddObserved

The event payload. Defaults to {}. Must match the schema for name (see EventData… below); unknown keys are dropped.

occurredAtstring (date-time)

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.

e.g. "2026-09-12T14:03:11.412Z"
seqinteger

Monotonic per-session counter for ordering events that share a timestamp. Non-numeric values are stored as 0.

default: 0e.g. 4
intentstring

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.

e.g. "eyJ2IjoxLCJqdGkiOiI..."
object

The conversation the event belongs to, if any.

object

The storefront page the shopper was on.

object

Client build information, for correlating behaviour with releases.

object

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.

AnalyticsSessionMeta

Per-visit metadata sent once per batch rather than on every event. Stored on each accepted event.

surfacestring

Where the events come from. Anything other than the listed values is recorded as widget.

One ofwidgetplayground
default: "widget"e.g. "widget"
storageModestring

The 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.

default: "memory"e.g. "full"
agentVersionstringnullable

Version of the client emitting the events.

e.g. "2.3.1"
tzstringnullable

IANA time zone of the shopper.

e.g. "America/Toronto"
localestringnullable

BCP 47 locale of the shopper.

e.g. "en-CA"
devicestringnullable

Device class, free text.

e.g. "mobile"
visitorIdstringnullable

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.

e.g. "4d2a9c1e-7b3f-4e8a-9c6d-1f2e3a4b5c6d"
sessionTokenstring

Fallback for the X-Stylor-Session header when headers cannot be set (for example sendBeacon). Ignored when the header is present.

e.g. "eyJ2IjoxLCJzaWQiOiI..."

Response

200The batch was processed. Check rejected for rows that were not stored.
acceptedintegerrequired

Events stored.

e.g. 2
array<object>required

Events that were not stored, with their position in events.

indexintegerrequired

0-based index into the submitted events array.

e.g. 1
reasonstringrequired

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.

e.g. "invalid_payload:charCount"

Errors

7

Every error is JSON with a single error string unless noted. Match on the status code; the message is for humans.

400
Request body must be valid JSON.

The body could not be parsed as JSON.

400
Request body must be a JSON object.

The body is empty, or is valid JSON that is not an object (for example an array).

401
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"
}
422
'events' must be an array.

events is missing or not an array.

422
A batch may contain at most 50 events.

events has more than 50 entries.

429
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.

500
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
      }
    }
  ]
}'
Response · 200
{
  "accepted": 2,
  "rejected": [
    {
      "index": 1,
      "reason": "invalid_payload:charCount"
    }
  ]
}