# REST API

Secret keys and the /v1/owner/ routes: forms, results with filters, answers and import.

The REST API lets your own server read your forms, their results and their answers, and import older answers. It is part of Max. Every route lives under `https://api.onestar.sh/v1/owner/` and opens only the workspace that made the key.

## Secret keys

Make a key in OneStar under **Settings**, **Feedback API**:

- Give it a name, like Production server, and choose whether it **Can import**. Every key can read; a key that can import also has the `import` scope.
- The key is shown once. Copy it into your server's configuration right away. It starts with `os_sk_`.
- The list shows each key's first characters, its scopes and when it was last used.
- **Revoke** stops a key at once. A workspace keeps up to 10 live keys.

A secret key reads every answer in your workspace. Keep it on your server: never put it in a web page, in browser code or in an app. The widget and the form pages never need one; they use only a form's public id.

Send the key as a bearer token on every request:

```bash curl
curl https://api.onestar.sh/v1/owner/forms \
  -H "Authorization: Bearer os_sk_..."
```

## Form ids

These routes take a form's `id`: 32 lower case hex characters, such as `9c2e41b07d5a4f31a8e6b2c9d0f71e54`. It is not the public id that starts with `f_`, which the widget uses. `GET /v1/owner/forms` lists both, and [webhook](https://onestar.sh/docs/feedback/webhooks.md) events name forms by `id` in `collector_id`.

## Routes

| Route | Scope | Returns |
|---|---|---|
| `GET /v1/owner/forms` | read | Your forms. |
| `GET /v1/owner/forms/{id}` | read | One form with its live questions, their ids and option ids. |
| `GET /v1/owner/forms/{id}/results` | read | Every question counted, with filters, one split and the load funnel. |
| `GET /v1/owner/forms/{id}/answers` | read | Answers newest first, a page at a time, with filters. |
| `POST /v1/owner/forms/{id}/answers` | import | Imports older answers. See [Importing answers](https://onestar.sh/docs/feedback/import.md). |

## List forms

```bash curl
curl https://api.onestar.sh/v1/owner/forms -H "Authorization: Bearer os_sk_..."
```

```json 200 OK
{
  "forms": [
    {
      "id": "9c2e41b07d5a4f31a8e6b2c9d0f71e54",
      "public_id": "f_hemsz66yupbd76d2",
      "title": "How was your run?",
      "kind": "survey",
      "state": "live",
      "product": { "id": "4f1d0c9a7b2e4d3c8a6b5e1f0d9c2a74", "name": "Driftwood" },
      "live_version": { "n": 3, "published_at": "2026-10-28T14:02:11.204512+00:00" },
      "answers_30d": 412
    }
  ]
}
```

`kind` is `reaction`, `nps`, `poll`, `survey` or `open`. `state` is `draft`, `live`, `paused` or `closed`. `live_version` is `null` until the first publish. `answers_30d` counts answers of the last 30 days, test answers left out. Archived forms are not listed.

## Get a form

Returns the live version's questions in order: the question ids and option ids you use to send answers from [your own UI](https://onestar.sh/docs/feedback/headless.md) and to import. It never returns the draft, the form's settings or its theme.

```bash curl
curl https://api.onestar.sh/v1/owner/forms/9c2e41b07d5a4f31a8e6b2c9d0f71e54 -H "Authorization: Bearer os_sk_..."
```

```json 200 OK
{
  "id": "9c2e41b07d5a4f31a8e6b2c9d0f71e54",
  "public_id": "f_hemsz66yupbd76d2",
  "title": "How was your run?",
  "kind": "survey",
  "state": "live",
  "product": { "id": "4f1d0c9a7b2e4d3c8a6b5e1f0d9c2a74", "name": "Driftwood" },
  "live_version": { "n": 3, "published_at": "2026-10-28T14:02:11.204512+00:00" },
  "blocks": [
    { "id": "b3f9a12c", "question": "How was your run?", "required": true, "type": "rating", "style": "stars", "points": 5 },
    { "id": "b7d01e55", "question": "What should we fix first?", "required": false, "show_if": { "block": "b3f9a12c", "op": "lt", "value": 4.0 }, "type": "text", "style": "long", "limit": 2000 },
    { "id": "b52c8e0a", "question": "Where do you play?", "required": false, "type": "choice", "options": [{ "id": "pc", "label": "PC" }, { "id": "deck", "label": "Steam Deck" }], "multiple": false, "other": false, "shuffle": false }
  ]
}
```

Each block has `id`, `type`, `question` and `required`, plus `help` and `show_if` when set, and the fields of its type: `style` and `points` (and any end labels) for a rating, `options` with `multiple`, `max_choices`, `other` and `shuffle` for a choice, `options` for a ranking, `style`, `limit` and `placeholder` for a text question. `blocks` is empty before the first publish.

## Filters

Results and answers take the same filters as **Results** in the app. Leave a filter out to include everything.

| Parameter | Value | Keeps |
|---|---|---|
| `version` | A version number | Only that version. By default every version with the same questions as the live one is counted together. |
| `placement` | A placement id (32 hex characters) | Answers from one placement. Ids come from `funnel.rows` and from `segments` split by `placement`. |
| `channel` | `embed`, `hosted` or `import` | Answers from the widget on your site, from the form's own page, or imported. |
| `level` | `anonymous`, `contact`, `signed_in` or `onestar` | Who answered: no contact, a typed contact, a [signed in user](https://onestar.sh/docs/feedback/identify-users.md) of your product, or a OneStar account. |
| `platform`, `app_version`, `plan`, `cohort` | A value of up to 40 characters | Answers whose page stated that value with `onestar.set`. See [Context and placements](https://onestar.sh/docs/feedback/context-and-placements.md). |
| `trait` with `trait_value` | A trait key and its value | Signed in users with that trait. Both are needed; `trait` alone is ignored. |
| `days` | 1 to 3650 | Answers from the last that many days. |

Values match exactly. Test answers are never included, and neither are answers removed by the person who sent them.

## Results

```bash curl
curl "https://api.onestar.sh/v1/owner/forms/9c2e41b07d5a4f31a8e6b2c9d0f71e54/results?days=30&dimension=platform" \
  -H "Authorization: Bearer os_sk_..."
```

`dimension` picks the one split returned in `segments`: `platform` (the default), `app_version`, `placement`, `level`, `channel`, `plan` or `cohort`. The response below is trimmed to the rating question.

```json 200 OK
{
  "form": { "id": "9c2e41b07d5a4f31a8e6b2c9d0f71e54", "title": "How was your run?", "kind": "survey", "state": "live" },
  "versions": [
    { "n": 2, "published_at": "2026-10-02T09:15:40.118220+00:00", "counted": true, "same_questions": true },
    { "n": 3, "published_at": "2026-10-28T14:02:11.204512+00:00", "counted": true, "same_questions": true }
  ],
  "shown_version": 3,
  "n": 120,
  "capped": false,
  "completed": { "k": 118, "share": 0.9833, "ci": [0.9413, 0.9954], "small": false },
  "wrote": { "k": 29, "share": 0.2417, "ci": [0.1739, 0.3255], "small": false },
  "identity": [
    { "level": "anonymous", "label": "Anonymous", "k": 89, "share": 0.7417, "ci": [0.6567, 0.8116], "small": false },
    { "level": "contact", "label": "Left a contact", "k": 0, "share": 0.0, "ci": [0.0, 0.031], "small": false },
    { "level": "signed_in", "label": "Signed in user", "k": 31, "share": 0.2583, "ci": [0.1884, 0.3433], "small": false },
    { "level": "onestar", "label": "OneStar account", "k": 0, "share": 0.0, "ci": [0.0, 0.031], "small": false }
  ],
  "blocks": [
    {
      "id": "b3f9a12c",
      "type": "rating",
      "question": "How was your run?",
      "count": {
        "type": "rating",
        "style": "stars",
        "n": 120,
        "distribution": [
          { "value": 1, "k": 14, "share": 0.1167, "ci": [0.0708, 0.1863], "small": false },
          { "value": 2, "k": 9, "share": 0.075, "ci": [0.04, 0.1364], "small": false },
          { "value": 3, "k": 14, "share": 0.1167, "ci": [0.0708, 0.1863], "small": false },
          { "value": 4, "k": 31, "share": 0.2583, "ci": [0.1884, 0.3433], "small": false },
          { "value": 5, "k": 52, "share": 0.4333, "ci": [0.3481, 0.5227], "small": false }
        ],
        "mean": { "mean": 3.817, "ci": [3.571, 4.062], "n": 120, "small": false },
        "top": { "from": 4, "k": 83, "share": 0.6917, "ci": [0.6042, 0.7673], "small": false },
        "bottom": { "to": 2, "k": 23, "share": 0.1917, "ci": [0.1313, 0.2712], "small": false }
      },
      "completion": { "k": 120, "share": 1.0, "ci": [0.969, 1.0], "small": false, "shown": 120 },
      "trend": [
        { "week": "2026-10-19", "value": 0.7213, "ci": [0.5983, 0.8181], "n": 61, "small": false, "unit": "share" },
        { "week": "2026-10-26", "value": 0.661, "ci": [0.5337, 0.7686], "n": 59, "small": false, "unit": "share" }
      ]
    }
  ],
  "segments": {
    "dimension": "platform",
    "trait": null,
    "block": "b3f9a12c",
    "rows": [
      { "key": "ios", "n": 62, "headline": { "value": 0.6935, "ci": [0.5703, 0.7942], "n": 62, "small": false, "unit": "share" } },
      { "key": "android", "n": 58, "headline": { "value": 0.6897, "ci": [0.562, 0.7938], "n": 58, "small": false, "unit": "share" } }
    ],
    "more": 0
  },
  "funnel": {
    "since": "2026-10-02",
    "loads": 1840,
    "started": 120,
    "finished": 118,
    "rows": [
      { "placement_id": "e81b5c3d20a94f7f9c6d1a2b3c4d5e6f", "channel": "embed", "mode": "inline", "origin": "https://driftwood.games", "name": "checkout", "loads": 1840, "started": 120, "finished": 118 }
    ],
    "more": 0
  },
  "filters": { "days": 30 }
}
```

What the fields mean:

- **Shares** come as `{ k, share, ci, small }`: the count, the share from 0 to 1, its 95% range, and `small` when too few answers stand behind it to lean on.
- `n` is the number of answers counted. `capped` is `true` when a form has more answers than one result counts; the newest are counted.
- `versions` lists every version; `counted` marks the ones in these figures, and `shown_version` is the version whose wording is shown.
- `completed` is the share of answers that were finished, and `wrote` the share with at least one written answer.
- `identity` splits answers by who answered.
- Each block has its `count` (its shape depends on the question type), its `completion` among the people it was shown to (`shown`), and for a rating or NPS question a weekly `trend` of its headline figure: the share at the happy end of a rating, or the NPS score.
- `segments` splits answers by `dimension`, largest first, with the headline of the first rating or NPS question (`block`). `key` is `null` for answers without that value, and `more` counts the groups not listed.
- `funnel` counts how often the form loaded, and how many answers were started and finished, per placement. Only `days` applies to it, never the other filters. It is `null` until a load has been counted. Headless and imported answers have no loads and stay out of it.
- `filters` echoes the filters that were applied.

## Answers

Answers come newest first, 50 a page. Pass the response's `next` as `before` to get the next page; `next` is `null` on the last page. Add `wrote=true` to list only answers with words in them. Every filter above applies.

```bash curl
curl "https://api.onestar.sh/v1/owner/forms/9c2e41b07d5a4f31a8e6b2c9d0f71e54/answers?wrote=true&level=signed_in" \
  -H "Authorization: Bearer os_sk_..."
```

```json 200 OK
{
  "items": [
    {
      "id": "5c0e7d2a9b8f4e61a3d2c4b7e9f01a6d",
      "created_at": "2026-10-30T18:44:02.551930+00:00",
      "version": 3,
      "channel": "embed",
      "mode": "inline",
      "origin": "https://driftwood.games",
      "level": "signed_in",
      "has_contact": true,
      "display_name": null,
      "completed": true,
      "platform": "ios",
      "app_version": "4.2.0",
      "locale": "en-US",
      "path": "/runs/summary",
      "quotable": false,
      "answers": [
        { "block": "b3f9a12c", "type": "rating", "question": "How was your run?", "value": 2.0, "choices": null, "text": null, "removed": false, "label": "2", "featured": false, "hidden": null },
        { "block": "b7d01e55", "type": "text", "question": "What should we fix first?", "value": null, "choices": null, "text": "The boss fight lags on older phones.", "removed": false, "label": "The boss fight lags on older phones.", "featured": false, "hidden": null }
      ]
    }
  ],
  "next": "0a7f3e9c2b1d4c58b6e2f1a0d9c8b7e6"
}
```

- `label` is the answer as a person reads it: the score, the option labels, or the text.
- A choice answer lists option ids in `choices`; a ranking lists them in order.
- A written answer longer than 280 characters is cut to 280 and ends with `...`. Read it in full in OneStar.
- `removed` is `true` when a written answer was removed; its `text` is then `null`.
- `has_contact` says whether the person left a contact. The contact itself never appears in the API; open the answer in OneStar to see it.
- `featured` and `hidden` reflect what you chose to show on the form's public page.

## Errors

Errors are JSON with a plain sentence in `detail`, and most carry a `code`:

```json 403 Forbidden
{ "detail": "This key does not have the import scope.", "code": "insufficient_scope" }
```

| Status | `code` | When |
|---|---|---|
| 401 | `auth_required` | No key, a malformed key, or a key that was revoked or never existed. |
| 402 | `upgrade_required` | The workspace is not on Max. The body also names the `feature`. |
| 403 | `insufficient_scope` | The key does not have the scope the route needs. |
| 404 | none | No such form in this workspace. A form of another workspace answers the same way. |
| 409 | `not_published` | An import into a form that was never published. |
| 413 | none | The request body is larger than 1 MB. |
| 422 | none | A parameter or body the API cannot read. `detail` is then a list of problems, each naming where it is. |
| 429 | none | Too many requests. Wait the number of seconds in the `Retry-After` header, then try again. |

A 5xx answer means something failed on OneStar's side; try again later with a growing wait.
