# Track events

`POST https://stylor.ai/api/v1/events`

- Authentication: none
- Web page: https://stylor.ai/guides/rest/v1/track-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.

## Example request

```bash
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
      }
    }
  ]
}'
```

## Headers

- `X-Stylor-Session` (string): The session token from widget preflight. Required unless `sessionToken` is sent in the body. [example `"eyJ2IjoxLCJzaWQiOiI..."`]
- `Origin` (string): Set automatically by browsers. When present and the session token carries a storefront origin, the two must match. [example `"https://shop.example.com"`]

## Request body

Content type `application/json`, required.

- `events` (array<AnalyticsEvent>, required): The batch. Empty is allowed; at most 50 entries. [max items 50]
  - `eventId` (string, required): Client-generated unique id (a UUID is conventional). Repeats within ten minutes are rejected as `duplicate`, so reuse the same id when retrying. [example `"0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f"`]
  - `name` (string, required): 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 of `"widget.loaded"`, `"page.viewed"`, `"widget.opened"`, `"widget.closed"`, `"message.sent"`, `"product.clicked"`, `"suggestion.clicked"`, `"conversation.cleared"`, `"question.answered"`, `"cart.item_added"`, `"cart.size_changed"`, `"cart.add_observed"`; example `"widget.opened"`]
  - `data` (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.
    - Option 1: EventDataWidgetLoaded
      - `storageMode` (string, required): Storage level the widget settled on after reading consent. [one of `"full"`, `"continuity"`, `"memory"`; example `"full"`]
      - `themeId` (string, nullable, required): Theme preset applied, if any. [example `"7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b"`]
      - `loadMs` (integer, nullable, required): Milliseconds from script start to ready. [minimum 0; example `412`]
    - Option 2: EventDataPageViewed
      - `pageType` (string, required): Page bucket. [one of `"home"`, `"product"`, `"collection"`, `"search"`, `"cart"`, `"other"`; example `"product"`]
      - `path` (string, nullable, required): Path only, never the query string. [max length 512; example `"/products/linen-camp-collar-shirt"`]
      - `productId` (string, nullable, required): The storefront's own product id on product pages. [max length 120; example `"8841234567890"`]
      - `restored` (boolean, required): `true` when the page came back from the back-forward cache rather than a fresh load. [example `false`]
    - Option 3: EventDataWidgetOpened
      - `trigger` (string, required): What opened it. [one of `"launcher"`, `"auto"`, `"deeplink"`; example `"launcher"`]
      - `msSincePageLoad` (integer, nullable, required): Milliseconds since the page loaded. [minimum 0; example `8120`]
    - Option 4: EventDataWidgetClosed
      - `openMs` (integer, required): How long the panel was open, in milliseconds. [minimum 0; example `393000`]
      - `reason` (string, required): How it was closed. [one of `"button"`, `"backdrop"`, `"swipe"`, `"unload"`; example `"unload"`]
    - Option 5: EventDataMessageSent
      - `inputType` (string, required): What kind of input produced the message. [one of `"text"`, `"image"`, `"chip"`, `"card"`; example `"text"`]
      - `charCount` (integer, required): Characters typed. [minimum 0; example `42`]
      - `imageCount` (integer, required): Images attached. [minimum 0; example `0`]
    - Option 6: EventDataProductClicked
      - `itemId` (string, required): The dataset item's `itemId`. [example `"9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f"`]
      - `position` (integer, nullable, required): 0-based position of the card in its surface. [minimum 0; example `2`]
      - `surface` (string, required): Whether the card was in a product row or a rendered look. [one of `"row"`, `"look"`; example `"row"`]
    - Option 7: EventDataSuggestionClicked
      - `index` (integer, required): 0-based index of the chip. [minimum 0; example `1`]
    - Option 8: EventDataConversationCleared
      - `turnCount` (integer, required): Turns in the conversation that was cleared. [minimum 0; example `3`]
    - Option 9: EventDataQuestionAnswered
      - `questionId` (string, required): Id of the question that was asked. [example `"5b4a3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d"`]
      - `answeredVia` (string, required): Whether a preset option or free text was used. [one of `"option"`, `"freeText"`; example `"option"`]
      - `msToAnswer` (integer, nullable, required): Milliseconds between the question appearing and the answer. [minimum 0; example `5400`]
    - Option 10: EventDataCartOutcome
      - `outcome` (string, required): Whether the storefront accepted the write. [one of `"ok"`, `"failed"`; example `"ok"`]
      - `error` (string, nullable, required): The storefront's error text when `outcome` is `failed`. [max length 240; example `null`]
    - Option 11: EventDataCartAddObserved
      - `attributed` (boolean, required): Whether the line matched something the stylist had shown in this session. [example `true`]
      - `matchedOn` (string, nullable, required): `sku` for an exact variant match, `handle` when a different variant of a shown product was added. Null when unattributed. [one of `"sku"`, `"handle"`; example `"sku"`]
      - `msSinceShown` (integer, nullable, required): Milliseconds between the product being shown and appearing in the cart. Null when unattributed. [minimum 0; example `61200`]
      - `quantity` (integer, required): Line quantity. [minimum 1; example `1`]
      - `priceCents` (integer, nullable, required): Line price in cents, as reported by the storefront. [minimum 0; example `8900`]
  - `occurredAt` (string (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. [example `"2026-09-12T14:03:11.412Z"`]
  - `seq` (integer): Monotonic per-session counter for ordering events that share a timestamp. Non-numeric values are stored as `0`. [default `0`; example `4`]
  - `intent` (string): 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. [example `"eyJ2IjoxLCJqdGkiOiI..."`]
  - `chat` (object): The conversation the event belongs to, if any.
    - `chatId` (string, nullable): Chat id. [example `"8b4d3f6e-2c1a-4f0e-9d7b-5a6c1e2f3a4b"`]
    - `turn` (integer, nullable): 0-based turn within the chat. Non-numeric values are stored as `null`. [example `1`]
  - `page` (object): The storefront page the shopper was on.
    - `path` (string, nullable): Path only, without query string. [example `"/products/linen-camp-collar-shirt"`]
    - `pageType` (string, nullable): Page bucket, for example `home`, `product`, `collection`, `search`, `cart`, `other`. [example `"product"`]
    - `productId` (string, nullable): The storefront's own product id, when on a product page. [example `"8841234567890"`]
  - `build` (object): Client build information, for correlating behaviour with releases.
    - `promptVersion` (string, nullable): Prompt version in use. [example `"2026-09-01"`]
    - `model` (string, nullable): Model in use. [example `"gpt-5.4"`]
    - `reasoningEffort` (string, nullable): Reasoning effort in use. [example `"low"`]
  - `attribution` (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.
    - `surfaceEventId` (string, nullable): `eventId` of the event that displayed the product. [example `"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"`]
    - `surfaceName` (string, nullable): Name of that event, for example `products.shown`. [example `"products.shown"`]
    - `position` (integer, nullable): 0-based position of the product in that surface. Non-numeric values are stored as `null`. [example `2`]
    - `sinceSurfaceMs` (integer, nullable): Milliseconds between the product being shown and this event. Non-numeric values are stored as `null`. [example `4300`]
    - `lookupId` (string, nullable): Id of the search that produced the surface. [example `"6f5e4d3c-2b1a-4f9e-8d7c-6b5a4f3e2d1c"`]
- `session` (AnalyticsSessionMeta): Per-visit metadata sent once per batch rather than on every event. Stored on each accepted event.
  - `surface` (string): Where the events come from. Anything other than the listed values is recorded as `widget`. [default `"widget"`; one of `"widget"`, `"playground"`; example `"widget"`]
  - `storageMode` (string): 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"`; example `"full"`]
  - `agentVersion` (string, nullable): Version of the client emitting the events. [example `"2.3.1"`]
  - `tz` (string, nullable): IANA time zone of the shopper. [example `"America/Toronto"`]
  - `locale` (string, nullable): BCP 47 locale of the shopper. [example `"en-CA"`]
  - `device` (string, nullable): Device class, free text. [example `"mobile"`]
  - `visitorId` (string, nullable): 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. [example `"4d2a9c1e-7b3f-4e8a-9c6d-1f2e3a4b5c6d"`]
- `sessionToken` (string): Fallback for the `X-Stylor-Session` header when headers cannot be set (for example `sendBeacon`). Ignored when the header is present. [example `"eyJ2IjoxLCJzaWQiOiI..."`]

### Examples

#### Engagement

A widget session opening and sending a message.

```json
{
  "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
      }
    }
  ]
}
```

#### Cart outcome

Report the result of a cart add with its intent token.

```json
{
  "events": [
    {
      "eventId": "2e3f4a5b-6c7d-4e8f-8a9b-0c1d2e3f4a5b",
      "name": "cart.item_added",
      "occurredAt": "2026-09-12T14:05:02.118Z",
      "seq": 9,
      "intent": "eyJ2IjoxLCJqdGkiOiI...",
      "chat": {
        "chatId": "8b4d3f6e-2c1a-4f0e-9d7b-5a6c1e2f3a4b",
        "turn": 2
      },
      "data": {
        "outcome": "ok",
        "error": null
      }
    }
  ]
}
```

#### Unload beacon

Send final events as the page closes, with the token in the body.

```json
{
  "sessionToken": "eyJ2IjoxLCJzaWQiOiI...",
  "events": [
    {
      "eventId": "3f4a5b6c-7d8e-4f9a-9b0c-1d2e3f4a5b6c",
      "name": "widget.closed",
      "occurredAt": "2026-09-12T14:09:44.001Z",
      "seq": 12,
      "data": {
        "openMs": 393000,
        "reason": "unload"
      }
    }
  ]
}
```

## Responses

### 200

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

Content type `application/json`:

- `accepted` (integer, required): Events stored. [example `2`]
- `rejected` (array<object>, required): Events that were not stored, with their position in `events`.
  - `index` (integer, required): 0-based index into the submitted `events` array. [example `1`]
  - `reason` (string, required): 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`. [example `"invalid_payload:charCount"`]

Example:

```json
{
  "accepted": 2,
  "rejected": [
    {
      "index": 1,
      "reason": "invalid_payload:charCount"
    }
  ]
}
```

## Errors

Every error is a JSON object with an `error` string. Match on the status code; the message is written for people.

| Status | Message | When |
| --- | --- | --- |
| 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).Body: `{"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. |
