> ## Documentation Index
> Fetch the complete documentation index at: https://seekeasy.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Event Tracking

> POST /v1/event - Report how users engage with the content we serve you

Tell us what users do with the posts returned by [Place Lookup](/place-lookup). These events are
how we learn which content earns attention and which drives conversions, which in turn is what lets us
return better posts for your venues over time.

Send one event per request. A valid event returns `202 Accepted`.

<Note>
  The video events must include a `slate_id`, which comes from the
  [Place Lookup](/place-lookup) response.
</Note>

## How a slate flows

<Steps>
  <Step title="Fetch the posts">
    Call [`GET /v1/place`](/place-lookup) once for each set of posts you're about to show. The
    response includes a `slate_id`.
  </Step>

  <Step title="Keep the slate_id">
    Store it alongside the posts in your page state. It belongs to the **page**, not to any
    individual post — every event from that set of posts carries the same `slate_id`.
  </Step>

  <Step title="Report interactions">
    As the user views, plays and moves through the posts, send events with the slate context
    below. Echo `place_id`, `post_id` and `slate_id` exactly as we served them.
  </Step>

  <Step title="Report conversions">
    Send `conversion_cta_click` when they tap your call to action, `conversion` when it goes
    through, and `conversion_outcome` once you know how it turned out.
  </Step>
</Steps>

## Authentication

<ParamField header="Authorization" type="string" required>
  `Bearer <your API key>`.
</ParamField>

## Request body

<ParamField body="event_data" type="object" required>
  The event itself. `event_type` selects which shape applies — the type-specific fields are listed
  under [Event types](#event-types).

  <Expandable title="event_data" defaultOpen={true}>
    <ParamField body="event_type" type="string" required>
      One of `post_impression`, `post_video_play`, `post_video_end`, `post_advance`,
      `conversion_cta_click`, `conversion` or `conversion_outcome`.
    </ParamField>

    <ParamField body="place_id" type="string" required>
      The `place_id` from the `/v1/place` response, echoed exactly. Max 300 characters.
    </ParamField>

    <ParamField body="post_id" type="string">
      The post involved, echoed exactly as served. Max 200 characters. **Required** on the four
      interaction events (`post_impression`, `post_video_play`, `post_video_end`, `post_advance`);
      optional on `conversion_cta_click` and `conversion`; not used on `conversion_outcome`.
    </ParamField>

    <ParamField body="session_id" type="string">
      Your identifier for the user's browsing session. Max 200 characters. **Required** on every
      event type that carries it — that's all of them except `conversion_outcome`, which reaches the
      session through the `conversion_id` it settles.

      It should span page views and live as long as you can reasonably keep it.
    </ParamField>

    <ParamField body="slate_id" type="string">
      The `slate_id` from the `/v1/place` response that delivered this post. Max 200 characters.
      **Required** on the four interaction events; optional on `conversion_cta_click` and
      `conversion`.
    </ParamField>

    <ParamField body="surface" type="string">
      Where the interaction happened — `inline` or `expanded`. **Required** on the four interaction
      events; optional on `conversion_cta_click`.

      `inline` is the post shown in place within the slate, typically small, muted and often
      autoplayed. `expanded` is a full-screen or lightbox player the user deliberately opened. A
      three-second muted tile play and a thirty-second deliberate watch are both "a play", so we
      report the two surfaces separately and never pool them.

      Layout isn't a surface — a grid or a vertical list is still `inline`.
    </ParamField>

    <ParamField body="position" type="integer">
      Zero-based index of this post within the slate.
    </ParamField>

    <ParamField body="slate_size" type="integer">
      Number of posts in the slate at the moment of the event.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="occurred_at" type="string">
  ISO-8601 timestamp of when the event happened on your side.
</ParamField>

<ParamField body="idempotency_key" type="string">
  Deduplicates retries: two requests with the same key are recorded once. Without it, a retried
  request is recorded as a second event. Max 200 characters.
</ParamField>

<ParamField body="distinct_id" type="string">
  Optional stable identifier for the user. Max 200 characters.
</ParamField>

<ParamField body="environment" type="string">
  Your own environment label, e.g. `staging`. Lets us separate your test traffic from real traffic
  when both point at the production URL. Max 100 characters.
</ParamField>

## Event types

<AccordionGroup>
  <Accordion title="post_impression — a post became visible">
    Send one whenever a post reaches **50% of its tile on screen for one continuous second** — an
    `IntersectionObserver` at `threshold: 0.5` plus a one-second timer is the usual implementation.
    Fire it for the inline tile, and again with `surface: "expanded"` when the post opens in the
    expanded player.

    ```json theme={null}
    {
      "event_type": "post_impression",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "surface": "inline",
      "position": 2,
      "slate_size": 20
    }
    ```
  </Accordion>

  <Accordion title="post_video_play — playback started">
    | Field      | Type    | Notes                                                       |
    | ---------- | ------- | ----------------------------------------------------------- |
    | `autoplay` | boolean | `true` when playback began without an explicit user action. |
    | `muted`    | boolean | `true` when playback began with audio muted.                |

    ```json theme={null}
    {
      "event_type": "post_video_play",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "surface": "expanded",
      "position": 2,
      "slate_size": 20,
      "autoplay": false,
      "muted": false
    }
    ```
  </Accordion>

  <Accordion title="post_video_end — playback stopped">
    Send one each time playback stops, not only when a video finishes — that's the video reaching
    its end (`ended`), the viewer moving to another post (`advanced`), dismissing the expanded
    player (`closed`), or leaving the page (`navigated_away`). A pause isn't a stop: hold the event
    while playback is paused, so a viewer who resumes is reported once when the view really ends,
    and one who never comes back is reported on unload as `navigated_away`.

    | Field                    | Type    | Notes                                                                                                                                             |
    | ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `reason`                 | string  | **Required.** One of `ended`, `advanced`, `closed`, `navigated_away`.                                                                             |
    | `watch_time_seconds`     | number  | Cumulative seconds watched. **Not** capped at the video's duration — a looping video accumulates past it, so don't clamp it.                      |
    | `percent_watched`        | number  | Furthest point reached in a single pass, `0`–`100`. On an abandonment this is where the viewer stopped.                                           |
    | `loop_count`             | integer | Times the video restarted after the first pass. `0` means it played through once. Use this for repeat viewing rather than a percentage above 100. |
    | `video_duration_seconds` | number  | Duration of the video as played. Must be greater than zero.                                                                                       |
    | `unmuted`                | boolean | `true` if the viewer turned audio on at any point during this view. `muted` on `post_video_play` only describes how playback started.             |

    <Note>
      `navigated_away` fires while the page is unloading, so send it with
      [`navigator.sendBeacon()`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon)
      or `fetch(..., { keepalive: true })`. An ordinary request gets cancelled on unload.
    </Note>

    ```json Finished theme={null}
    {
      "event_type": "post_video_end",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "surface": "expanded",
      "position": 2,
      "reason": "ended",
      "watch_time_seconds": 42.5,
      "percent_watched": 100,
      "loop_count": 1,
      "video_duration_seconds": 28.0,
      "unmuted": true
    }
    ```

    ```json Abandoned theme={null}
    {
      "event_type": "post_video_end",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "surface": "inline",
      "position": 2,
      "reason": "advanced",
      "watch_time_seconds": 3.2,
      "percent_watched": 11,
      "loop_count": 0,
      "video_duration_seconds": 28.0,
      "unmuted": false
    }
    ```
  </Accordion>

  <Accordion title="post_advance — the user moved to another post">
    `post_id` and `position` describe the post being advanced **from**.

    | Field           | Type    | Notes                                                                                                                                                                       |
    | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `method`        | string  | **Required.** One of `next_click`, `prev_click`, `swipe`, `scroll`, `autoadvance`. Use `scroll` for layouts with no discrete next control, such as a grid or vertical list. |
    | `from_position` | integer | Zero-based index advanced from.                                                                                                                                             |
    | `to_position`   | integer | Zero-based index advanced to.                                                                                                                                               |

    Applies on both surfaces — swiping inside the full-screen player counts too.

    ```json theme={null}
    {
      "event_type": "post_advance",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "surface": "inline",
      "position": 2,
      "method": "swipe",
      "from_position": 2,
      "to_position": 3
    }
    ```
  </Accordion>

  <Accordion title="conversion_cta_click — the user tapped your call to action">
    Send one when the user taps your call to action — "Book a Table", or whatever the equivalent is
    on your surface.

    `place_id` and `session_id` are required on every one.

    `post_id`, `slate_id` and `surface` are optional, because a call to action doesn't have to sit
    on a post:

    * **Tapped on a post** — a button on a tile, or one inside the expanded player. Send all three,
      plus `position`, so the tap is attributed to the content that drove it.
    * **Tapped on the page** — a header, footer or sidebar button belonging to the place rather than
      to any one post. Send `place_id` and `session_id` on their own.

    ```json theme={null}
    {
      "event_type": "conversion_cta_click",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "surface": "expanded",
      "position": 2
    }
    ```
  </Accordion>

  <Accordion title="conversion — the conversion happened">
    `place_id`, `conversion_id` and `session_id` are required: `conversion_id` is what a later
    `conversion_outcome` matches on to settle this conversion, and `session_id` ties it back to the
    browsing that led to it.

    `post_id` and `slate_id` stay optional — a conversion needn't originate from a particular post —
    but sending them is what attributes it to the content that drove it.

    | Field           | Type    | Notes                                                                                                                                                                                       |
    | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `conversion_id` | string  | **Required.** Your identifier for the conversion. Must be stable across this event and any later `conversion_outcome` that settles it.                                                      |
    | `quantity`      | integer | Units on the conversion — covers on a reservation, items on an order (minimum 1). This is the figure at conversion time; what was actually fulfilled comes through on `conversion_outcome`. |
    | `scheduled_for` | string  | ISO-8601 date and time the conversion is scheduled for, where that differs from when it was made — a reservation's sitting, a delivery slot.                                                |

    ```json theme={null}
    {
      "event_type": "conversion",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "conversion_id": "ot_res_44813",
      "post_id": "post_xyz789",
      "session_id": "sess_8f2b5a6d",
      "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
      "position": 2,
      "quantity": 4,
      "scheduled_for": "2026-06-14T19:30:00Z"
    }
    ```
  </Accordion>

  <Accordion title="conversion_outcome — the outcome of a conversion">
    Settles a conversion, and doubles as the correction channel: a `no_show` or `cancelled` reverses
    what an earlier `conversion` recorded.

    | Field                  | Type    | Notes                                                                                                                                                                                                                  |
    | ---------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `conversion_id`        | string  | **Required.** Matches the one sent on the `conversion` event.                                                                                                                                                          |
    | `status`               | string  | **Required.** One of `fulfilled`, `no_show`, `cancelled`.                                                                                                                                                              |
    | `quantity_fulfilled`   | integer | Units actually fulfilled — diners seated, items delivered. Zero or omitted when nothing was.                                                                                                                           |
    | `revenue_amount_minor` | integer | Revenue in the minor unit of `revenue_currency` (e.g. cents), as a non-negative integer — never a float. `status` carries the direction, so a reversal is a `cancelled`/`no_show` event rather than a negative amount. |
    | `revenue_currency`     | string  | ISO 4217 code, e.g. `USD`. Required whenever `revenue_amount_minor` is set.                                                                                                                                            |

    ```json theme={null}
    {
      "event_type": "conversion_outcome",
      "place_id": "8a2830828b77fff:Rosewater Cafe",
      "conversion_id": "ot_res_44813",
      "status": "fulfilled",
      "quantity_fulfilled": 4,
      "revenue_amount_minor": 24500,
      "revenue_currency": "USD"
    }
    ```

    <Note>
      Fulfilment and revenue figures currently feed analytics on a best-effort path. Treat them as directional
      rather than a system of record until we tell you otherwise.
    </Note>
  </Accordion>
</AccordionGroup>

## Response

<ResponseField name="success" type="boolean">
  Always `true` on a `202`. The event has been validated and queued.
</ResponseField>

### Errors

| Status | Meaning                                                                                            |
| ------ | -------------------------------------------------------------------------------------------------- |
| `401`  | No API key was sent. Add `Authorization: Bearer <your API key>`.                                   |
| `403`  | The key is invalid, or is missing permission to record events.                                     |
| `422`  | Validation failed. The body names the offending field, e.g. `event_data.slate_id: Field required`. |

<Note>
  Retry `5xx` responses and network failures with the **same `idempotency_key`**. A `422` isn't
  worth retrying — it will fail identically until the payload changes.
</Note>

<RequestExample>
  ```bash cURL theme={null}
  curl --request POST \
    --url "https://api.seekeasy.ai/v1/event" \
    --header "Authorization: Bearer YOUR_API_KEY" \
    --header "Content-Type: application/json" \
    --data '{
      "event_data": {
        "event_type": "post_video_play",
        "place_id": "8a2830828b77fff:Rosewater Cafe",
        "post_id": "post_xyz789",
        "session_id": "sess_8f2b5a6d",
        "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
        "surface": "expanded",
        "position": 2,
        "slate_size": 20,
        "autoplay": false,
        "muted": false
      },
      "occurred_at": "2026-06-01T12:00:00Z",
      "idempotency_key": "evt-8f2b5a6d-0007"
    }'
  ```

  ```python Python theme={null}
  import requests

  requests.post(
      "https://api.seekeasy.ai/v1/event",
      headers={"Authorization": "Bearer YOUR_API_KEY"},
      json={
          "event_data": {
              "event_type": "post_video_play",
              "place_id": "8a2830828b77fff:Rosewater Cafe",
              "post_id": "post_xyz789",
              "session_id": "sess_8f2b5a6d",
              "slate_id": "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
              "surface": "expanded",
              "position": 2,
              "slate_size": 20,
              "autoplay": False,
              "muted": False,
          },
          "occurred_at": "2026-06-01T12:00:00Z",
          "idempotency_key": "evt-8f2b5a6d-0007",
      },
  )
  ```

  ```javascript JavaScript theme={null}
  await fetch("https://api.seekeasy.ai/v1/event", {
    method: "POST",
    headers: {
      "Authorization": "Bearer YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event_data: {
        event_type: "post_video_play",
        place_id: "8a2830828b77fff:Rosewater Cafe",
        post_id: "post_xyz789",
        session_id: "sess_8f2b5a6d",
        slate_id: "b1f0c2d4-9e3a-4c17-8f2b-5a6d7e8f9012",
        surface: "expanded",
        position: 2,
        slate_size: 20,
        autoplay: false,
        muted: false,
      },
      occurred_at: "2026-06-01T12:00:00Z",
      idempotency_key: "evt-8f2b5a6d-0007",
    }),
  });
  ```
</RequestExample>

<ResponseExample>
  ```json 202 theme={null}
  {
    "success": true
  }
  ```

  ```json 422 theme={null}
  {
    "detail": "event_data.slate_id: Field required"
  }
  ```
</ResponseExample>
