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

# Study ledger and events

> Read what a study has learned — the Study Manager's versioned ledger of questions, answer patterns, themes with graded evidence, must-reads and phase — and the log of what changed.

Every study has a Study Manager: an agent that reads each quality interview in full and keeps one document, the **ledger**, describing what the study has learned. The ledger holds the questions the study is answering, the answer patterns and themes found so far, a graded quote from every interview that supports each of them, the interviews worth reading, and the agent's call on where the study stands.

The ledger is written only by the study's agent. Every save creates a new version and records what changed as **events**. Both are read-only over the API: you read the ledger for the current picture and the events for the delta since you last looked.

<Note>
  `GET /v1/studies/:id/themes` remains the older automatic themes, regenerated from scratch after each interview. The ledger is the Study Manager's account: versioned, graded, and counted the same way for every item.
</Note>

***

## `GET /v1/studies/:id/ledger`

Retrieve the current version of a study's ledger.

### Request

<ParamField header="Authorization" type="string" required>
  Bearer token. See [Authentication](/api/authentication).
</ParamField>

<ParamField path="id" type="string" required>
  Study ID (UUID).
</ParamField>

<ParamField query="include" type="string">
  `"all"` to include merged and retired items. Default: active items only.
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl https://app.userjourneys.ai/api/v1/studies/e5f6a7b8-1234-56cd-ef78-222222222222/ledger \
    -H "Authorization: Bearer uj_live_your_key_here"
  ```

  ```javascript Node.js theme={null}
  const response = await fetch(
    "https://app.userjourneys.ai/api/v1/studies/e5f6a7b8-1234-56cd-ef78-222222222222/ledger",
    {
      headers: {
        Authorization: "Bearer uj_live_your_key_here",
      },
    }
  );
  const ledger = await response.json();
  // ledger.questions[0].answers — answer patterns with graded evidence
  // ledger.must_reads — the interviews worth reading, by rank
  ```

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

  response = requests.get(
      "https://app.userjourneys.ai/api/v1/studies/e5f6a7b8-1234-56cd-ef78-222222222222/ledger",
      headers={"Authorization": "Bearer uj_live_your_key_here"},
  )
  ledger = response.json()
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "object": "study_ledger",
    "study_id": "e5f6a7b8-1234-56cd-ef78-222222222222",
    "version": 7,
    "updated_at": "2026-09-17T09:14:03.000Z",
    "phase": {
      "value": "collecting",
      "reason": "The last three interviews each added a new pattern to the onboarding question."
    },
    "questions": [
      {
        "object": "study_question",
        "id": "q_first_impression",
        "text": "What shapes the first impression of the product?",
        "source": "study:0",
        "status": "emerging",
        "note": null,
        "answers": [
          {
            "object": "study_item",
            "id": "a_setup_before_value",
            "kind": "answer",
            "question_id": "q_first_impression",
            "title": "Setup steps come before anything useful",
            "description": "Respondents describe connecting accounts and inviting teammates before seeing their own data.",
            "status": "active",
            "merged_into": null,
            "reason": null,
            "created_by_interview_id": "a1b2c3d4-5678-90ab-cdef-111111111111",
            "created_at": "2026-09-12T16:40:11.000Z",
            "supporters": 4,
            "weak": 1,
            "reviewed": 9,
            "evidence": [
              {
                "object": "study_evidence",
                "interview_id": "a1b2c3d4-5678-90ab-cdef-111111111111",
                "start": 61,
                "end": 68,
                "quote": "I had to connect three things before I saw a single chart.",
                "gloss": null,
                "grade": "A",
                "grade_reason": "Raised unprompted while describing the first session."
              },
              {
                "object": "study_evidence",
                "interview_id": "b2c3d4e5-6789-01bc-def0-222222222222",
                "start": 143,
                "end": 149,
                "quote": "Yeah, the invites, I guess that was a bit much.",
                "gloss": "Asked directly whether the setup felt long.",
                "grade": "D",
                "grade_reason": "Only agrees with the interviewer's framing."
              }
            ]
          }
        ]
      }
    ],
    "themes": [
      {
        "object": "study_item",
        "id": "t_pricing_page_confusion",
        "kind": "theme",
        "question_id": null,
        "title": "The pricing page is read as per-seat",
        "description": "Respondents say they expected to pay for each teammate they invited.",
        "status": "active",
        "merged_into": null,
        "reason": null,
        "created_by_interview_id": "c3d4e5f6-7890-12cd-ef01-333333333333",
        "created_at": "2026-09-15T10:02:47.000Z",
        "supporters": 3,
        "weak": 0,
        "reviewed": 9,
        "evidence": [
          {
            "object": "study_evidence",
            "interview_id": "c3d4e5f6-7890-12cd-ef01-333333333333",
            "start": 210,
            "end": 216,
            "quote": "I assumed every person I added would cost extra, so I stopped at two.",
            "gloss": null,
            "grade": "A",
            "grade_reason": "Concrete consequence the respondent brought up on their own."
          }
        ]
      }
    ],
    "must_reads": [
      {
        "interview_id": "c3d4e5f6-7890-12cd-ef01-333333333333",
        "rank": 1,
        "reason": "Walks through the whole first session and names the pricing assumption that stopped invites."
      }
    ],
    "excluded": [
      {
        "interview_id": "d4e5f6a7-8901-23de-f012-444444444444",
        "reason": "Respondent evaluated a competitor's product, not ours."
      }
    ],
    "notes": [
      {
        "id": "n_tenure_split",
        "kind": "observation",
        "text": "Respondents with an existing account skip the setup complaints entirely.",
        "created_at": "2026-09-16T08:30:00.000Z"
      }
    ],
    "preferences": [
      {
        "text": "Track whether respondents mention the mobile app at all.",
        "created_at": "2026-09-14T11:05:22.000Z"
      }
    ]
  }
  ```
</ResponseExample>

Before the study's agent has saved its first version, the endpoint returns `404` with `error.code` `"ledger_not_found"`:

```json 404 theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "ledger_not_found",
    "message": "No ledger yet",
    "param": null
  }
}
```

Returns `404` with `error.code` `"study_not_found"` if the study doesn't exist or doesn't belong to your project.

***

## `GET /v1/studies/:id/events`

List what changed in a study's ledger, newest first. Each event is derived from the difference between two ledger versions; nothing is computed beside that diff.

### Request

<ParamField header="Authorization" type="string" required>
  Bearer token. See [Authentication](/api/authentication).
</ParamField>

<ParamField path="id" type="string" required>
  Study ID (UUID).
</ParamField>

<ParamField query="since" type="string">
  Only events created at or after this ISO 8601 timestamp (inclusive), e.g. `2026-09-17T00:00:00Z`. An event's `created_at` can be passed back as is; URL-encode it, because a `+` in the offset otherwise decodes as a space.
</ParamField>

<ParamField query="kind" type="string">
  Only events of this kind. Repeat the parameter for several kinds: `?kind=theme_created&kind=item_strengthened`. See [Event kinds](#event-kinds).
</ParamField>

<ParamField query="limit" type="integer">
  Number of events to return. Default `20`, max `100`.
</ParamField>

<ParamField query="starting_after" type="string">
  Cursor for pagination. Pass the `id` of the last event from the previous page.
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl "https://app.userjourneys.ai/api/v1/studies/e5f6a7b8-1234-56cd-ef78-222222222222/events?since=2026-09-17T00:00:00Z" \
    -H "Authorization: Bearer uj_live_your_key_here"
  ```

  ```javascript Node.js theme={null}
  const studyId = "e5f6a7b8-1234-56cd-ef78-222222222222";
  const response = await fetch(
    `https://app.userjourneys.ai/api/v1/studies/${studyId}/events?since=2026-09-17T00:00:00Z`,
    {
      headers: {
        Authorization: "Bearer uj_live_your_key_here",
      },
    }
  );
  const { data, has_more } = await response.json();
  ```

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

  study_id = "e5f6a7b8-1234-56cd-ef78-222222222222"
  response = requests.get(
      f"https://app.userjourneys.ai/api/v1/studies/{study_id}/events",
      params={"since": "2026-09-17T00:00:00Z"},
      headers={"Authorization": "Bearer uj_live_your_key_here"},
  )
  data = response.json()["data"]
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "object": "list",
    "data": [
      {
        "object": "study_event",
        "id": "0f9b2c1e-4d3a-4b5c-8e6f-555555555555",
        "study_id": "e5f6a7b8-1234-56cd-ef78-222222222222",
        "ledger_version": 7,
        "kind": "item_strengthened",
        "item_id": "t_pricing_page_confusion",
        "interview_id": null,
        "detail": {
          "map": "themes",
          "from": 2,
          "to": 3,
          "threshold": 3
        },
        "created_at": "2026-09-17T09:14:03.000Z"
      },
      {
        "object": "study_event",
        "id": "1a8c3d2f-5e4b-4c6d-9f7a-666666666666",
        "study_id": "e5f6a7b8-1234-56cd-ef78-222222222222",
        "ledger_version": 7,
        "kind": "interview_reviewed",
        "item_id": null,
        "interview_id": "e5f6a7b8-9012-34ef-0123-555555555555",
        "detail": {
          "supports": 2,
          "weak": 0
        },
        "created_at": "2026-09-17T09:14:03.000Z"
      },
      {
        "object": "study_event",
        "id": "2b9d4e3a-6f5c-4d7e-af8b-777777777777",
        "study_id": "e5f6a7b8-1234-56cd-ef78-222222222222",
        "ledger_version": 6,
        "kind": "phase_changed",
        "item_id": null,
        "interview_id": null,
        "detail": {
          "from": null,
          "to": "collecting",
          "reason": "First three quality interviews reviewed; patterns still forming."
        },
        "created_at": "2026-09-16T08:30:00.000Z"
      }
    ],
    "has_more": false
  }
  ```
</ResponseExample>

Returns `404` with `error.code` `"study_not_found"` if the study doesn't exist or doesn't belong to your project. Before the first save the list is empty, not an error.

***

## Errors

These two endpoints return a structured error body. `type` is `"invalid_request_error"` for `400` and `404` responses and `"api_error"` for `500`. `param` names the query or path parameter the error is about, or is `null`.

```json 400 theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "event_not_found",
    "message": "No such event: 0f9b2c1e-4d3a-4b5c-8e6f-555555555555",
    "param": "starting_after"
  }
}
```

| Status | `code`              | When                                                                                                                                                                 |
| ------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_parameter` | A parameter fails validation: a malformed `id`, an unknown `include` value, a `since` that is not an ISO 8601 timestamp, a `limit` outside 1–100, an unknown `kind`. |
| `400`  | `event_not_found`   | `starting_after` is not an event of this study.                                                                                                                      |
| `404`  | `study_not_found`   | The study doesn't exist or doesn't belong to your project.                                                                                                           |
| `404`  | `ledger_not_found`  | The study's agent has not saved a ledger yet.                                                                                                                        |
| `500`  | `internal_error`    | Something went wrong on our side.                                                                                                                                    |

Missing or invalid API keys return `401` with the [shared error body](/api/index#error-handling).

***

## Study ledger object

<ResponseField name="object" type="string">
  `"study_ledger"`
</ResponseField>

<ResponseField name="study_id" type="string">
  Study ID (UUID).
</ResponseField>

<ResponseField name="version" type="integer">
  The ledger version, starting at `1` on the agent's first save and increasing by one per save. Events carry the version that produced them.
</ResponseField>

<ResponseField name="updated_at" type="string">
  ISO 8601 timestamp of the save that produced this version.
</ResponseField>

<ResponseField name="phase" type="object | null">
  The agent's call on where the study stands, with its reason. `null` until the agent has made one.

  <Expandable title="properties">
    <ResponseField name="value" type="string">
      `"collecting"` (new interviews still add patterns or change question statuses), `"saturated"` (the last several interviews only strengthened what exists), or `"answered"` (the questions have stable, well-supported answers).
    </ResponseField>

    <ResponseField name="reason" type="string">
      Why the agent made this call, in its own words.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="questions" type="study_question[]">
  The questions the study is answering, each with its answer patterns. The study's configured questions come first in the study's order, then questions derived from the research goal, then questions a person asked for. Answer patterns and themes are listed strongest first. See [Study question](#study-question).
</ResponseField>

<ResponseField name="themes" type="study_item[]">
  Findings nobody asked for. See [Study item](#study-item).
</ResponseField>

<ResponseField name="must_reads" type="object[]">
  The interviews worth reading, by rank. At most five.

  <Expandable title="properties">
    <ResponseField name="interview_id" type="string">
      Interview ID (UUID). Retrieve it with [`GET /v1/interviews/:id`](/api/interviews#get-v1interviewsid).
    </ResponseField>

    <ResponseField name="rank" type="integer">
      `1` to `5`; `1` is the first to read.
    </ResponseField>

    <ResponseField name="reason" type="string">
      Why this interview earns its rank.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="excluded" type="object[]">
  Interviews the agent set aside. An excluded interview has no evidence and is not counted.

  <Expandable title="properties">
    <ResponseField name="interview_id" type="string">
      Interview ID (UUID).
    </ResponseField>

    <ResponseField name="reason" type="string">
      Why it was excluded, for example an off-cohort or contaminated respondent.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="notes" type="object[]">
  Observations, caveats and method remarks that fit no item. Visible, never counted.

  <Expandable title="properties">
    <ResponseField name="id" type="string">
      Note ID, e.g. `"n_tenure_split"`.
    </ResponseField>

    <ResponseField name="kind" type="string">
      `"observation"`, `"caveat"`, or `"method"`.
    </ResponseField>

    <ResponseField name="text" type="string">
      The note.
    </ResponseField>

    <ResponseField name="created_at" type="string">
      ISO 8601 timestamp.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="preferences" type="object[]">
  What the study owner told the agent to track or drop, in chat. Preferences bind on every later turn.

  <Expandable title="properties">
    <ResponseField name="text" type="string">
      The preference as the agent recorded it.
    </ResponseField>

    <ResponseField name="created_at" type="string">
      ISO 8601 timestamp.
    </ResponseField>
  </Expandable>
</ResponseField>

### Study question

<ResponseField name="object" type="string">
  `"study_question"`
</ResponseField>

<ResponseField name="id" type="string">
  Question ID, e.g. `"q_first_impression"`. Never reused.
</ResponseField>

<ResponseField name="text" type="string">
  The question as the agent phrased it. The agent derives its own list from the study's goal and configured questions, and may split or rephrase them.
</ResponseField>

<ResponseField name="source" type="string">
  Where the question came from: `"study:<n>"` for the study's configured question `n` (0-indexed), `"goal"` when derived from the research goal, or `"user"` when a person asked for it in chat.
</ResponseField>

<ResponseField name="status" type="string">
  `"no_pattern"` (no answer pattern yet), `"emerging"` (at least one pattern, counts still moving), or `"answered"` (patterns stable; the agent would defend the answer).
</ResponseField>

<ResponseField name="note" type="string | null">
  Free text about the question, e.g. `"splits by tenure"`.
</ResponseField>

<ResponseField name="answers" type="study_item[]">
  The answer patterns found for this question. Several per question is normal. See [Study item](#study-item).
</ResponseField>

### Study item

An answer pattern or a theme. Both carry the same fields and the same counts.

<ResponseField name="object" type="string">
  `"study_item"`
</ResponseField>

<ResponseField name="id" type="string">
  Item ID: `"a_…"` for answers, `"t_…"` for themes. Never reused.
</ResponseField>

<ResponseField name="kind" type="string">
  `"answer"` or `"theme"`.
</ResponseField>

<ResponseField name="question_id" type="string | null">
  The question an answer belongs to. `null` for themes.
</ResponseField>

<ResponseField name="title" type="string">
  Names the pattern. Titles never state how many respondents; the counts do.
</ResponseField>

<ResponseField name="description" type="string">
  What respondents did or said.
</ResponseField>

<ResponseField name="status" type="string">
  `"active"` (counted and shown), `"merged"` (folded into `merged_into`), or `"retired"` (no longer tracked). Merged and retired items appear only with `include=all`.
</ResponseField>

<ResponseField name="merged_into" type="string | null">
  The item this one was folded into, when `status` is `"merged"`.
</ResponseField>

<ResponseField name="reason" type="string | null">
  Why the item was merged or retired.
</ResponseField>

<ResponseField name="created_by_interview_id" type="string | null">
  The interview that first surfaced the item.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 timestamp of the save that created the item.
</ResponseField>

<ResponseField name="supporters" type="integer">
  Interviews whose evidence for this item is graded `A`, `B` or `C`. This is the item's number; see [Grades and counts](#grades-and-counts).
</ResponseField>

<ResponseField name="weak" type="integer">
  Interviews whose evidence for this item is graded `D` or `E`. Not counted in `supporters`.
</ResponseField>

<ResponseField name="reviewed" type="integer">
  Interviews the agent has reviewed and not excluded, in this ledger version. Read `supporters` against it: `4` of `9` reviewed.
</ResponseField>

<ResponseField name="evidence" type="study_evidence[]">
  One graded quote per supporting interview. See [Study evidence](#study-evidence).
</ResponseField>

### Study evidence

<ResponseField name="object" type="string">
  `"study_evidence"`
</ResponseField>

<ResponseField name="interview_id" type="string">
  Interview ID (UUID) the quote comes from.
</ResponseField>

<ResponseField name="start" type="number">
  Seconds into the interview where the quoted line starts.
</ResponseField>

<ResponseField name="end" type="number">
  Seconds into the interview where the quoted line ends. May equal `start`.
</ResponseField>

<ResponseField name="quote" type="string">
  The respondent's verbatim words, at most thirty words. Every quote is checked against the transcript before the ledger is saved.
</ResponseField>

<ResponseField name="gloss" type="string | null">
  Context the words alone would not carry, such as the interviewer's question. Present when the grade depends on it.
</ResponseField>

<ResponseField name="grade" type="string">
  `"A"` to `"E"`. See [Grades and counts](#grades-and-counts).
</ResponseField>

<ResponseField name="grade_reason" type="string">
  One sentence, in the agent's words, saying what earned the grade.
</ResponseField>

### Grades and counts

Every piece of evidence is a graded quote. The grade says how much the respondent's words earn for the item:

| Grade | Meaning                                                                                                     | Counted |
| ----- | ----------------------------------------------------------------------------------------------------------- | ------- |
| `A`   | The respondent raised it themselves and gave something concrete.                                            | Yes     |
| `B`   | A direct, substantive answer to the interviewer's question.                                                 | Yes     |
| `C`   | Supports the item but hedged, partial, or only meaningful with the gloss.                                   | Yes     |
| `D`   | The words only mean this because of how the question was asked. The interviewer's question is in the gloss. | No      |
| `E`   | A stretch.                                                                                                  | No      |

An item's `supporters` is the number of reviewed, non-excluded interviews with evidence graded `A` to `C`; `weak` is the number graded `D` or `E`. Both are counted over the same `reviewed` set, so `supporters` means the same thing for every item in a ledger. Excluded interviews contribute to neither. Titles and descriptions never state prevalence; compare `supporters` with `reviewed` instead.

***

## Study event object

<ResponseField name="object" type="string">
  `"study_event"`
</ResponseField>

<ResponseField name="id" type="string">
  Event ID (UUID). Use it as `starting_after` to page.
</ResponseField>

<ResponseField name="study_id" type="string">
  Study ID (UUID).
</ResponseField>

<ResponseField name="ledger_version" type="integer">
  The ledger version whose save produced this event.
</ResponseField>

<ResponseField name="kind" type="string">
  One of the [event kinds](#event-kinds).
</ResponseField>

<ResponseField name="item_id" type="string | null">
  The question, item or note the event is about, when there is one.
</ResponseField>

<ResponseField name="interview_id" type="string | null">
  The interview the event is about, when there is one.
</ResponseField>

<ResponseField name="detail" type="object">
  Small facts about the change. Keys depend on `kind`; see [Event kinds](#event-kinds). Never contains quotes.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 timestamp of the save that produced the event.
</ResponseField>

### Event kinds

`map` in `detail` is `"answers"` or `"themes"`, naming which kind of item the event is about.

| `kind`                    | When                                                                                   | `item_id` / `interview_id` | `detail` keys                                         |
| ------------------------- | -------------------------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------- |
| `question_created`        | A new question.                                                                        | question / —               | `source`, `status`                                    |
| `question_status_changed` | A question's status changed.                                                           | question / —               | `from`, `to`                                          |
| `answer_created`          | A new answer pattern.                                                                  | item / —                   | `title`, `question`, `supporters`, `weak`, `reviewed` |
| `theme_created`           | A new theme.                                                                           | item / —                   | `title`, `supporters`, `weak`, `reviewed`             |
| `item_merged`             | An item's status became `merged`.                                                      | item / —                   | `map`, `merged_into`, `reason`                        |
| `item_retired`            | An item's status became `retired`.                                                     | item / —                   | `map`, `reason`                                       |
| `item_redefined`          | An item's title or description changed.                                                | item / —                   | `map`, `fields` (the changed field names)             |
| `item_strengthened`       | An active item's `supporters` crossed 3, 5, 10 or 20. One event per threshold crossed. | item / —                   | `map`, `from`, `to`, `threshold`                      |
| `interview_reviewed`      | A new interview was reviewed.                                                          | — / interview              | `supports`, `weak`                                    |
| `stance_changed`          | A reviewed interview's set of supported items changed.                                 | — / interview              | `added`, `removed` (item ids)                         |
| `stance_regraded`         | A piece of evidence was regraded.                                                      | item / interview           | `interview`, `item`, `from`, `to` (grades)            |
| `must_read_added`         | An interview was made a must-read.                                                     | — / interview              | `rank`, `reason`                                      |
| `must_read_changed`       | A must-read was re-ranked.                                                             | — / interview              | `from_rank`, `to_rank`                                |
| `must_read_removed`       | A must-read was dropped.                                                               | — / interview              | `from_rank`                                           |
| `interview_excluded`      | An interview was excluded.                                                             | — / interview              | `reason`                                              |
| `note_added`              | A note was added.                                                                      | note / —                   | `kind`, `note_id`                                     |
| `phase_changed`           | The phase value changed. A reason-only edit is not an event.                           | — / —                      | `from`, `to`, `reason`                                |

<Tip>
  To watch a study, poll `GET /v1/studies/:id/events?since=<last poll>` and read the ledger when something you care about appears, for example `theme_created` or `item_strengthened`. Webhooks do not deliver study-level events in v1.
</Tip>
