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
importscope. - 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:
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 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. |
List forms#
curl https://api.onestar.sh/v1/owner/forms -H "Authorization: Bearer os_sk_..."{
"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 and to import. It never returns the draft, the form's settings or its theme.
curl https://api.onestar.sh/v1/owner/forms/9c2e41b07d5a4f31a8e6b2c9d0f71e54 -H "Authorization: Bearer os_sk_..."{
"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 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. |
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#
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.
{
"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, andsmallwhen too few answers stand behind it to lean on. nis the number of answers counted.cappedistruewhen a form has more answers than one result counts; the newest are counted.versionslists every version;countedmarks the ones in these figures, andshown_versionis the version whose wording is shown.completedis the share of answers that were finished, andwrotethe share with at least one written answer.identitysplits answers by who answered.- Each block has its
count(its shape depends on the question type), itscompletionamong the people it was shown to (shown), and for a rating or NPS question a weeklytrendof its headline figure: the share at the happy end of a rating, or the NPS score. segmentssplits answers bydimension, largest first, with the headline of the first rating or NPS question (block).keyisnullfor answers without that value, andmorecounts the groups not listed.funnelcounts how often the form loaded, and how many answers were started and finished, per placement. Onlydaysapplies to it, never the other filters. It isnulluntil a load has been counted. Headless and imported answers have no loads and stay out of it.filtersechoes 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.
curl "https://api.onestar.sh/v1/owner/forms/9c2e41b07d5a4f31a8e6b2c9d0f71e54/answers?wrote=true&level=signed_in" \
-H "Authorization: Bearer os_sk_..."{
"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"
}labelis 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. removedistruewhen a written answer was removed; itstextis thennull.has_contactsays whether the person left a contact. The contact itself never appears in the API; open the answer in OneStar to see it.featuredandhiddenreflect 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:
{ "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.