Partner Integration: Embedded SSO & Platform API

How to embed Outset in your product with OIDC single sign-on, and the full v2 Platform API surface — existing, planned, and proposed — for an end-to-end study lifecycle: creation, fielding, analysis, and export.

0.1 (draft for internal review) 2026-08-11 Draft — proposed endpoints are not committed
Chapter 01

Integration Overview #

Outset is an AI-moderated research platform: researchers design studies, recruit participants, and AI conducts and analyzes the interviews. This document describes how a partner platform integrates Outset end to end — embedding the product for their users, and driving the full study lifecycle programmatically.

Two integration surfaces, one trust relationship

Both surfaces build on the same foundation: your identity provider signs JWTs, Outset verifies them against your JWKS endpoint. You register one OIDC provider configuration with Outset (issuer, JWKS URL, client ID) and it powers everything below. There is no shared secret at rest, and key rotation is transparent — publish a new key to your JWKS before signing with it.

Surface What it is for Credential Chapter
Embedded UI Your users work inside the Outset product, framed in your own. Full researcher experience with zero login friction. Per-session OIDC ID token, handed to the iframe over postMessage. 2
Platform API (/v2/) Your backend drives Outset programmatically: provision organizations, create and launch studies, read results, export data. Per-request signed JWT assertion (partner principal, RFC 7523) or a user-delegated OAuth 2.1 token. 3 onward

The two compose. A common pattern: your backend creates and configures a study via the API, your users refine and monitor it in the embedded UI, your backend pulls insights and exports when fielding completes. Users and organizations are provisioned just-in-time on first SSO login. Organizations are addressed by your identifiers throughout the API — the same org_id your tokens carry; users carry Outset-issued IDs, returned by GET /v2/users/.

Outset also exposes an MCP (Model Context Protocol) server for AI-agent integrations, secured with the same OAuth 2.1 flow — one token works on both surfaces. If your integration is agent-driven, ask your Outset contact for the Outset MCP — Integration Guide for Developers.

How to read the endpoint reference

Every endpoint in chapters 4–15 carries one of three status badges:

  • EXISTING — live on /v2/ today. You can build against it now.
  • PLANNED — designed and scheduled on the Outset roadmap. Shapes may shift in detail but not in substance.
  • PROPOSED — a concrete proposal for discussion with integration partners, not yet committed.

The reference covers the entire lifecycle:

  1. Platform — identity, organizations, users, budgets, usage (ch. 4)
  2. Structure — workspaces and projects (ch. 5)
  3. Build — studies, content, screeners, media, languages (ch. 6–10)
  4. Field — recruitment, quotas, invites (ch. 11)
  5. Observe — interviews and transcripts (ch. 12)
  6. Understand — reports, insights, themes, emotion and vision analysis, highlight reels (ch. 13–14)
  7. Deliver — exports and webhooks (ch. 15–16)

Scope

Deliberately out of scope for this document:

  • Panel-monitoring endpoints (a separate surface for recruitment-panel partners).
  • Report authoring — creating a report stays in the product; the API exposes every generated report and insight read-only, plus a rerun of an existing report.
  • Human-moderated interviews — the API covers AI-moderated studies.
Chapter 02

Embedded UI with OIDC Single Sign-On #

You embed Outset in an <iframe>, hand it a signed OIDC ID token for the current user over postMessage, and Outset starts an authenticated session. No OAuth server, no redirect flow, no Outset login page. Users and organizations are created just-in-time on first login.

This chapter condenses the full partner guide (Outset OIDC Embed SSO — Integration Guide for Partners); the mechanics below are complete enough to implement against.

The flow

# Actor Action
1 Your page Embeds <iframe src="https://outset.ai/sso/oidc/embed/<your-slug>/">
2 Outset iframe Posts IFRAME_READY when loaded
3 Your page Mints a signed ID token for the current user, posts OIDC_AUTH with it
4 Outset Validates the JWT against your JWKS, provisions user/org if needed, starts a session
5 Outset iframe Posts OIDC_COMPLETE (success or error), then shows the authenticated app
window.addEventListener("message", (event) => {
  if (event.origin !== "https://outset.ai") return;
  if (event.data.type === "IFRAME_READY") {
    iframe.contentWindow.postMessage(
      { type: "OIDC_AUTH", id_token: mintTokenForCurrentUser() },
      "https://outset.ai"  // explicit target origin — never "*"
    );
  }
  if (event.data.type === "OIDC_COMPLETE" && !event.data.success) {
    console.error(event.data.error.code, event.data.error.message);
  }
});

Two generic embed-lifecycle messages also apply: URL_CHANGED (Outset → you; deep-linking and navigation tracking) and LOGOUT (you → Outset; ends the embedded session).

The ID token

A standard JWT, signed RS256 only, verified against the JWKS endpoint you host (a static HTTPS JSON file is fine). Key rotation is transparent: publish the new key before signing with it.

Claim Required Notes
iss, aud, exp, iat Your issuer URL; the client ID Outset assigns you; tokens with iat older than 10 minutes (configurable) are rejected.
sub Stable unique user ID on your side. The identity anchor — returning users match on this, never on email. Never reuse across users.
email Rejected if email_verified is present and false.
org_id Your workspace/account ID. Each distinct value maps to its own Outset organization.
nonce Random one-time value; replayed tokens are rejected.
given_name, family_name, org_name optional Display names; org_name names a newly provisioned organization.

If your IdP already issues these under different claim names, Outset maps to your names during setup — you do not change how you sign tokens.

Provisioning

  • First login: an Outset organization is created for the org_id (if none exists) and a user account for the sub, joined with the default role configured for your integration (User or Observer).
  • Returning users match by sub; email and name changes flow in from the token; roles assigned inside Outset are kept.
  • Email conflict: if a new sub presents an email that already belongs to an unlinked Outset account, login is blocked (ACCOUNT_CONFLICT) rather than silently linking — for migrations, Outset backfills the links before go-live.

Security model

  • You: HTTPS everywhere, fresh token per session, explicit postMessage target origin, stable sub values. An optional shared secret on the token exchange is available per-partner.
  • Outset: RS256-only verification against your JWKS, full claim validation, single-use replay cache (fails closed), frame-ancestors CSP scoped to exactly your registered origins (fails closed), origin check on every inbound message, rate-limited token exchange.

Errors

OIDC_COMPLETE carries error.code on failure: INVALID_TOKEN, MISSING_CLAIMS, PROVIDER_DISABLED, PROVIDER_NOT_CONFIGURED, PROVISIONING_DISABLED, ACCOUNT_CONFLICT, USER_INACTIVE.

Setup checklist

You provide: JWKS URL, issuer URL, every embed origin (including dev), custom claim names if any, and any existing-user migration list before go-live. Outset provides: your embed slug and URL, your client ID (aud), the default role for provisioned users, and a staging environment for end-to-end testing.

Chapter 03

Platform API: Authentication & Conventions #

Everything in chapters 4–15 lives under one base path — /v2/ on the API host for your region (communicated during onboarding; US and EU stacks are separate deployments). The API publishes its own machine-readable contract: GET /v2/openapi.json (no auth required) and a hosted reference viewer at GET /v2/docs/. The OpenAPI document is generated from the deployed code on every request, so it cannot drift.

Credentials

Every request carries Authorization: Bearer <credential>. Two credential types reach the same endpoints; each endpoint in this document lists which it accepts.

User-delegated OAuth 2.1 tokenexisting. Standard authorization-code flow with PKCE (S256, required), discovered via GET /.well-known/oauth-authorization-server. Clients register through RFC 7591 Dynamic Client Registration or by presenting an https client-ID metadata URL. Access tokens live 1 hour; refresh tokens rotate on every use and live 30 days. At consent time the user pins the token to one organization and a workspace selection; the token can never widen that grant, and refreshes re-check that the user is still a member. One token works on both the Platform API and the MCP server.

Partner assertionplanned. Your backend signs a short-lived JWT (≤ 5 minutes, single-use jti) with the same keys your OIDC embed tokens use, and sends it directly as the bearer credential — no token exchange, no long-lived secret at rest. The target organization travels inside the signed assertion (outset_org, carrying your own external org identifier), so nothing between your backend and Outset can retarget a request. One credential reaches every organization mapped to your provider; scopes are configured per-provider and fail closed. This is the credential for estate-level automation: provisioning client organizations, managing users, reading cross-org usage, funding budgets.

Requests without a valid credential get 401 with WWW-Authenticate: Bearer. Organizations must be entitled to the Platform API (subscription flag); tokens for non-entitled organizations are rejected at authentication time.

Scopes

Scopes use a resource:verb vocabulary. Mutating scopes end in :write or :launch:launch is deliberately separate because publish/close and recruitment operations hit production panels and can incur real fees.

Scope Grants Status
projects:read List projects, workspaces, and organization info existing
studies:read Read study guides, questions, screener config existing
studies:write Create and edit projects, studies, content, screeners, media, languages, recruitment config existing
studies:launch Publish, unpublish, close studies; launch or expand recruitment existing
analytics:read Read interviews, reports, insights, exports existing
analytics:write Create highlight reels, correct transcripts, flag/redact PII existing
usage:read Per-user/per-project usage metrics existing
wallet:read Recruitment wallet balance existing
webhooks:manage Register and manage webhook subscriptions reserved
organizations:read / organizations:write Partner estate: list, provision, deactivate organizations planned
users:read / users:write Partner estate: membership management planned
budget:read / budget:write Partner estate: per-organization spend caps planned

The proposed endpoints in this document deliberately reuse the existing scope vocabulary — a new scope forces every existing integration to re-authorize, so one is added only when a genuinely new authority appears (the planned partner-estate scopes above).

Two role-based guardrails apply to user-delegated tokens on top of scopes, enforced at consent time and again on every request: :write/:launch scopes require an editing organization role, and analytics:read (participant output — transcripts, answers, reports) is denied to administrative non-researcher roles. Partner assertions are bounded by their per-provider scope configuration instead; role belts do not apply.

Request and response conventions

  • IDs are UUIDs. Timestamps are ISO 8601, UTC.
  • Single objects come wrapped: {"data": { ... }}.
  • Lists are cursor-paginated: {"data": [ ... ], "next_cursor": "<opaque|null>", "has_more": bool} with page_size (default 50, max 200) and cursor query params. Cursors are opaque bare tokens; ordering is newest-first with a stable tiebreaker.
  • Errors share one envelope:
{
  "status": "error",
  "errors": [
    { "code": "not_found", "detail": "Interview not found.", "field": null }
  ]
}

Three usage-endpoint responses (400 result_too_large, 502, 503) are raw {"detail": …} rather than the envelope.

  • 404, never 403, for invisible resources. When a credential cannot see a resource by ID — wrong organization, workspace outside the grant — the API answers as if it does not exist. A 403 is reserved for scope, role, and organization-policy denials; a scope denial names the missing scope explicitly.
  • Workspace scoping. Every workspace-scoped read and write intersects the token's granted workspaces with the caller's current membership. The intersection can only narrow over time. Partner assertions scope to the non-archived workspaces of the organization named in the assertion.
  • Writes are strict. Unknown fields in a request body are rejected, not ignored — a misspelled optional field fails loudly instead of silently doing nothing.
  • Files move through short-lived presigned URLs, in both directions. Uploads: the API issues a presigned PUT URL and a server-chosen key, you upload the bytes, then confirm — the API validates the real bytes at commit. Downloads (recordings, exports): the API returns a presigned URL or 302-redirects to one; URLs expire within minutes and must not be persisted. Two surfaces are deliberately durable and unsigned — content images embedded in question text, and highlight-reel share links — and both are documented as publicly readable by whoever holds the URL.
  • Long-running work is asynchronous. Operations that render files or fan out processing (exports, highlight reels, translations, synthetic responses) return 202 with a job resource to poll. Terminal states are COMPLETED / FAILED; job results carry presigned URLs.
  • Rate limits: authenticated callers get 600 requests/min (burst); a few expensive analytics endpoints carry tighter per-organization limits and answer 429 (or 503 + Retry-After while a shared cache warms).
  • PII protection is enforced below the API. Interviews whose PII scan has not completed are withheld (404); messages under review are masked ("[PII Under Review]"); redacted content never leaves the platform, on any surface.

Versioning

The /v2/ prefix is stable across additive changes; the OpenAPI document carries a semantic version that is bumped whenever the surface changes shape. A breaking change would ship as /v3/ — additive evolution (new endpoints, new optional fields) is the strong default, and this whole document is additive on /v2/.

Chapter 04

Platform: Identity, Organizations & Usage #

These are the endpoints that describe who you are and what your estate looks like, rather than any single study. GET /v2/me/ confirms a credential and shows exactly which organization and workspaces it reaches — the first call to make in any integration. The usage endpoints report per-user, per-project and per-study activity for invoicing and seat review, and the recruitment wallet reports the organization's settled credit balance. The remaining routes are the partner estate: a partner credential that authenticates without a user can list and pre-provision client organizations, read a cross-organization usage aggregate, and set per-organization spend caps against one central funding balance.

Identify the current credential #
GET /v2/me/ existing
Scope none Credentials: user-delegated

Returns the user, organization, OAuth client, consented scopes, and effective workspaces behind the bearer token. Every valid token can call it, so a client can verify its credential before attempting a scope-gated endpoint.

Response

Single-object envelope.

FieldTypeDescription
data.user.iduuidThe authenticated user.
data.user.emailemailEmail address of the authenticated user.
data.user.namestringFull display name of the authenticated user.
data.organization.iduuidThe organization the token is pinned to.
data.organization.namestringName of that organization.
data.application.namestringDisplay name of the OAuth client the token was issued to.
data.application.client_idstringOAuth client identifier the token was issued to.
data.scopesarray[string]The scopes the user consented to, verbatim — the authoritative list of what this token may do.
data.workspaces.all_workspaces_grantedbooleanWhether consent covered current and future workspaces rather than a fixed selection.
data.workspaces.items[].iduuidA workspace this token can actually reach — the token's grant intersected with the user's current membership.
data.workspaces.items[].namestringName of that workspace.
{
  "data": {
    "user": {
      "id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
      "email": "researcher@example.com",
      "name": "Dana Okonjo"
    },
    "organization": {
      "id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
      "name": "Northwind Insights"
    },
    "application": {
      "name": "Northwind Research Portal",
      "client_id": "cli_9d3a2f17b8e4"
    },
    "scopes": [
      "projects:read",
      "studies:read",
      "analytics:read"
    ],
    "workspaces": {
      "all_workspaces_granted": false,
      "items": [
        {
          "id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
          "name": "Brand Tracking"
        }
      ]
    }
  }
}
Errors
StatusCodeWhen
401authentication_failedA partner assertion is presented. A partner credential has no authenticated person, so this resource has no meaning for it and the rejection names the required credential type rather than a missing scope.

Archived workspaces, and the hidden simulation workspace used for digital twins, never appear in items. The list is recomputed on every call, so a workspace the user has since lost access to disappears without the token changing.

Read your own organization #
GET /v2/organization/ proposed
Scope projects:read Credentials: user-delegated, partner

Returns the organization the credential is acting on. Singular by design: it always resolves from the credential and takes no identifier, so a caller can never read another organization through it.

Response

Single-object envelope.

FieldTypeDescription
data.iduuidOutset identifier of the organization the credential is acting on.
data.namestringDisplay name of the organization.
{
  "data": {
    "id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
    "name": "Northwind Insights"
  }
}

Do not confuse this with /v2/organizations/ (plural). This route is the caller's own organization; the plural collection is the partner estate — the set of client organizations mapped to a partner's identity provider — and is not reachable with a user-delegated token today.

Per-user usage rollup #
GET /v2/usage/ existing
Scope usage:read Credentials: user-delegated, partner

One row per user in the organization for a date range: their logins, engagement sessions, and the projects, studies, and completed interviews they produced. This is the endpoint behind seat review and monthly invoicing.

Query parameters
NameTypeDescription
start_date required date (YYYY-MM-DD) First day of the reporting window, inclusive, in UTC.
end_date required date (YYYY-MM-DD) Last day of the reporting window, inclusive, in UTC; must be on or after start_date and no more than 366 days later.
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list, one row per user, ordered by email ascending.

FieldTypeDescription
data[].user_iduuid, nullableOutset user ID, or null when no current user maps to this email (for example a removed account).
data[].emailemailEmail identifying the user. Rows are keyed and ordered by this value.
data[].logins[].login_iduuidUnique ID of this login event in the auth audit log.
data[].logins[].login_atdatetimeWhen the login occurred (ISO 8601, UTC).
data[].logins[].auth_methodstringHow the user authenticated — for example credentials, google, saml, or oidc.
data[].engagement_session_countintegerCount of distinct product-usage sessions (30-minute inactivity buckets) — actual active usage, which a single login can span several of, or none.
data[].projects_createdintegerProjects this user created in the date range (excludes demo and template projects).
data[].studies_createdintegerStudies this user created in the date range (excludes templates; includes soft-deleted studies).
data[].interview_completes_rawintegerCompleted interviews on this user's studies in the date range, including low-quality ones (excludes fraudulent and archived interviews).
data[].interview_completes_validintegerThe high-quality subset of interview_completes_raw — low-quality interviews excluded.
data[].total_interview_duration_secondsfloatTotal recorded duration, in seconds, of the valid (non-low-quality) completed interviews.
{
  "data": [
    {
      "user_id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
      "email": "researcher@example.com",
      "logins": [
        {
          "login_id": "d4a17e60-5b2c-42f9-8e31-9c07ab5d6e12",
          "login_at": "2026-07-14T08:21:07Z",
          "auth_method": "oidc"
        }
      ],
      "engagement_session_count": 12,
      "projects_created": 2,
      "studies_created": 5,
      "interview_completes_raw": 214,
      "interview_completes_valid": 197,
      "total_interview_duration_seconds": 148320.5
    }
  ],
  "next_cursor": "cmVzZWFyY2hlckBleGFtcGxlLmNvbQ==",
  "has_more": true
}
Errors
StatusCodeWhen
400result_too_largeThe window produces more rows than the usage data source returns. Request a shorter window.
502NoneThe usage data source is temporarily unavailable. The endpoint fails loudly rather than serving a misleadingly low count. This response is outside the standard error envelope — the body carries only `detail`.
503NoneAnother request is already filling the cache for this organization. Retry after the seconds given in `Retry-After`. This response is outside the standard error envelope — the body carries only `detail`.

Three access requirements apply on top of the usage:read scope, and all three must hold: the Usage API must be enabled for your organization (Outset enables it during onboarding); the caller's organization role must be Admin or Non-researcher admin — an organization-wide role, not a workspace one; and the token must carry an organization-wide workspace grant (all workspaces, not a selection). Each failure is a 403 naming the specific requirement. This endpoint is throttled per organization (30 requests/hour by default) because it queries a shared analytics backend. A user who created work but never logged in still appears, with an empty logins list; simulation (digital-twin) projects and studies are excluded throughout. The 400 result_too_large, 502 and 503 bodies from the usage routes use {"detail": …} rather than the standard error envelope.

Engagement sessions #
GET /v2/usage/sessions/ existing
Scope usage:read Credentials: user-delegated, partner

One row per product-engagement session in the date range — a 30-minute inactivity bucket of real product usage, not a login. Use it when the rolled-up engagement_session_count on /v2/usage/ needs breaking down.

Query parameters
NameTypeDescription
start_date required date (YYYY-MM-DD) First day of the reporting window, inclusive, in UTC.
end_date required date (YYYY-MM-DD) Last day of the reporting window, inclusive, in UTC; the window may not exceed 366 days.
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor; a malformed value is a 400.
Response

Cursor-paginated list, newest session first.

FieldTypeDescription
data[].user_iduuidOutset user ID the engagement session belongs to.
data[].session_idstringAnalytics session identifier — a 30-minute inactivity bucket, not a login event.
data[].started_atdatetimeStart of the session: its first event (ISO 8601, UTC).
data[].ended_atdatetimeEnd of the session: its last recorded event (ISO 8601, UTC).
data[].duration_secondsintegerElapsed time from the first to the last event in the session, in seconds.
data[].event_countintegerNumber of product events recorded in the session.
{
  "data": [
    {
      "user_id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
      "session_id": "01J9F4T2Q8N3S7VB0KX5M2Y6ZD",
      "started_at": "2026-07-14T08:22:11Z",
      "ended_at": "2026-07-14T09:07:48Z",
      "duration_seconds": 2737,
      "event_count": 184
    }
  ],
  "next_cursor": "cD0yMDI2LTA3LTE0VDA4JTNBMjIlM0ExMVo",
  "has_more": true
}

Same three access requirements and the same per-organization throttle as /v2/usage/. Results are cached per organization and date range for five minutes behind a single-flight lock, so a repeated call within that window is served from cache; a window whose result exceeds the upstream row cap returns 400 result_too_large.

Login events #
GET /v2/usage/logins/ existing
Scope usage:read Credentials: user-delegated, partner

One row per successful server-side sign-in in the date range — the flat counterpart of the logins array on /v2/usage/. A login is an auth event and a session is a window of product engagement; the two do not map one-to-one.

Query parameters
NameTypeDescription
start_date required date (YYYY-MM-DD) First day of the reporting window, inclusive, in UTC.
end_date required date (YYYY-MM-DD) Last day of the reporting window, inclusive, in UTC; the window may not exceed 366 days.
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list, newest login first.

FieldTypeDescription
data[].login_iduuidUnique ID of this login event in the auth audit log.
data[].user_iduuid, nullableOutset user ID that logged in, or null if no current user maps to the email.
data[].user_emailemailEmail of the user that logged in.
data[].login_atdatetimeWhen the login occurred (ISO 8601, UTC).
data[].auth_methodstringHow the user authenticated — for example credentials, google, saml, or oidc.
{
  "data": [
    {
      "login_id": "d4a17e60-5b2c-42f9-8e31-9c07ab5d6e12",
      "user_id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
      "user_email": "researcher@example.com",
      "login_at": "2026-07-14T08:21:07Z",
      "auth_method": "oidc"
    }
  ],
  "next_cursor": "cD0yMDI2LTA3LTE0VDA4JTNBMjElM0EwN1o",
  "has_more": true
}

Same three access requirements as /v2/usage/ (Usage API enabled, organization-wide role, organization-wide workspace grant). No endpoint-specific throttle applies — this one reads Outset's own audit log rather than the shared analytics backend.

Usage by project #
GET /v2/usage/projects/ existing
Scope usage:read Credentials: user-delegated, partner

Completed-interview volume and recorded duration for the date range, grouped by project. The unit of invoicing for customers who bill per project.

Query parameters
NameTypeDescription
start_date required date (YYYY-MM-DD) First day of the reporting window, inclusive, in UTC.
end_date required date (YYYY-MM-DD) Last day of the reporting window, inclusive, in UTC; the window may not exceed 366 days.
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor; a malformed value is a 400.
Response

Cursor-paginated list, one row per project, ordered by project ID ascending.

FieldTypeDescription
data[].project_iduuidID of the project the interviews' studies belong to.
data[].project_namestringName of the project.
data[].interview_completes_rawintegerCompleted interviews across this project's studies in the date range, including low-quality ones (excludes fraudulent and archived interviews).
data[].interview_completes_validintegerThe high-quality subset of interview_completes_raw — low-quality interviews excluded.
data[].total_interview_duration_secondsfloatTotal recorded duration, in seconds, of the valid (non-low-quality) completed interviews.
{
  "data": [
    {
      "project_id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
      "project_name": "Spring refresh — packaging concepts",
      "interview_completes_raw": 214,
      "interview_completes_valid": 197,
      "total_interview_duration_seconds": 148320.5
    }
  ],
  "next_cursor": "cD1iNDFlN2YyOC0wYTZkLTRjOTM",
  "has_more": true
}

Same three access requirements as /v2/usage/. Studies that are not assigned to a project are omitted entirely, so these rows can total less than /v2/usage/studies/ for the same window — reconcile against the study-level route, not against this one.

Usage by study #
GET /v2/usage/studies/ existing
Scope usage:read Credentials: user-delegated, partner

Completed-interview volume and recorded duration for the date range, grouped by study. Same window rules and pagination as the project rollup, keyed on study instead.

Query parameters
NameTypeDescription
start_date required date (YYYY-MM-DD) First day of the reporting window, inclusive, in UTC.
end_date required date (YYYY-MM-DD) Last day of the reporting window, inclusive, in UTC; the window may not exceed 366 days.
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor; a malformed value is a 400.
Response

Cursor-paginated list, one row per study, ordered by study ID ascending.

FieldTypeDescription
data[].study_iduuidID of the study (interview definition).
data[].study_namestringName of the study.
data[].project_iduuid, nullableID of the project this study belongs to, or null if it is not in a project.
data[].interview_completes_rawintegerCompleted interviews on this study in the date range, including low-quality ones (excludes fraudulent and archived interviews).
data[].interview_completes_validintegerThe high-quality subset of interview_completes_raw — low-quality interviews excluded.
data[].total_interview_duration_secondsfloatTotal recorded duration, in seconds, of the valid (non-low-quality) completed interviews.
{
  "data": [
    {
      "study_id": "5e8c1a37-92d4-4b60-8f75-3a0e6c9d2b41",
      "study_name": "Packaging concept test — wave 2",
      "project_id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
      "interview_completes_raw": 120,
      "interview_completes_valid": 111,
      "total_interview_duration_seconds": 83640.0
    }
  ],
  "next_cursor": "cD01ZThjMWEzNy05MmQ0LTRiNjA",
  "has_more": true
}

Same three access requirements as /v2/usage/ (Usage API enabled, organization-wide role, organization-wide workspace grant).

Recruitment wallet balance #
GET /v2/recruitment-wallet/ existing
Scope wallet:read Credentials: user-delegated, partner

Returns the organization's settled recruitment credit balance at read time. Check it before launching or expanding recruitment, which draws against this balance.

Response

Single-object envelope.

FieldTypeDescription
data.organization_iduuidThe organization the credential is acting on.
data.wallet_balance_usdstring (decimal)Settled wallet balance in USD, serialized as a string so no precision is lost in JSON parsing.
data.as_ofdatetimeWhen the balance was read (ISO 8601, UTC).
{
  "data": {
    "organization_id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
    "wallet_balance_usd": "4250.00",
    "as_of": "2026-08-11T14:03:22Z"
  }
}

The balance is settled, not unallocated — recruitment already launched but not yet fully fielded is not deducted here, so a launch can still be blocked on funds while this reads positive. The wallet is organization-level and deliberately ignores the token's workspace grant; there is no per-workspace wallet. The organization always comes from the credential, never from a parameter.

List the organizations in your estate #
GET /v2/organizations/ planned
Scope organizations:read Credentials: partner

Enumerates every organization mapped to your identity provider, with the identifier you provision them under and the read-only list of workspaces inside each. This is how a partner takes inventory of its estate.

Query parameters
NameTypeDescription
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list, newest organization first.

FieldTypeDescription
data[].external_org_idstringYour own identifier for this organization — the value you address it by everywhere in the API.
data[].organization_iduuidOutset's identifier for the organization.
data[].namestringDisplay name of the organization.
data[].statusenumWhether the organization is active or has been deactivated. Deactivation semantics are still being designed, so the exact set of values and their effects may change before general availability.
data[].created_atdatetimeWhen the organization was created in Outset (ISO 8601, UTC).
data[].workspaces[].iduuidA workspace inside this organization.
data[].workspaces[].namestringName of that workspace.
data[].workspaces[].is_defaultbooleanWhether this is the organization's default workspace, created automatically with it.
{
  "data": [
    {
      "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
      "organization_id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
      "name": "Northwind Insights",
      "status": "ACTIVE",
      "created_at": "2026-06-02T09:14:55Z",
      "workspaces": [
        {
          "id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
          "name": "Default",
          "is_default": true
        }
      ]
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Estate-scoped: the partner assertion must not carry an organization claim on this route. A claim present here is a 400, so a request meant for one organization can never be answered with the whole estate. The workspace list is read-only; workspace creation and archival stay in the product UI for now, and a dedicated /v2/workspaces/ route is reserved for when write access lands.

Provision an organization #
POST /v2/organizations/ planned
Scope organizations:write Credentials: partner

Creates an Outset organization for one of your clients and maps it to your own identifier. Use it to pre-provision an organization — set a spend cap, share templates — before any of that client's users has logged in.

Request body

The external identifier to map, plus the organization's display name.

FieldTypeDescription
external_org_id required string Your own identifier for this organization; it becomes the value you address the organization by on every other route.
name required string Display name for the organization as researchers will see it.
{
  "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
  "name": "Northwind Insights"
}
Response

Single-object envelope carrying the same shape as a row from the list endpoint.

FieldTypeDescription
data.external_org_idstringYour identifier for the organization, as supplied.
data.organization_iduuidOutset's identifier for the newly created (or already existing) organization.
data.namestringDisplay name of the organization.
data.statusenumWhether the organization is active or has been deactivated. Deactivation semantics are still being designed, so the exact set of values and their effects may change before general availability.
data.created_atdatetimeWhen the organization was created in Outset (ISO 8601, UTC).
data.workspaces[]array[object]Workspaces inside the organization; a freshly created organization has exactly one, its default workspace.
{
  "data": {
    "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
    "organization_id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
    "name": "Northwind Insights",
    "status": "ACTIVE",
    "created_at": "2026-08-11T14:05:01Z",
    "workspaces": [
      {
        "id": "9a8c4e70-1b2f-4d63-a5e8-77c0d31b9f24",
        "name": "Default",
        "is_default": true
      }
    ]
  }
}

Idempotent on external_org_id. Posting an identifier that is already mapped returns the existing organization instead of creating a second one, so a retried or replayed provisioning call is safe. A default workspace is created with the organization automatically. Estate-scoped: the assertion must not carry an organization claim.

Read one organization in your estate #
GET /v2/organizations/{external_org_id}/ planned
Scope organizations:read Credentials: partner

Reads a single organization by your own identifier for it, with its workspace list.

Path parameters
NameTypeDescription
external_org_idstringYour identifier for the organization. For clients migrated from a single shared organization this is the legacy workspace UUID, so identifiers you already store keep resolving.
Response

Single-object envelope; same shape as a row from the list endpoint.

FieldTypeDescription
data.external_org_idstringYour own identifier for this organization — the value you address it by everywhere in the API.
data.organization_iduuidOutset's identifier for the organization.
data.namestringDisplay name of the organization.
data.statusenumWhether the organization is active or has been deactivated. Deactivation semantics are still being designed, so the exact set of values and their effects may change before general availability.
data.created_atdatetimeWhen the organization was created in Outset (ISO 8601, UTC).
data.workspaces[].iduuidA workspace inside this organization. The list is read-only.
data.workspaces[].namestringName of that workspace.
data.workspaces[].is_defaultbooleanWhether this is the organization's default workspace, created automatically with it.
{
  "data": {
    "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
    "organization_id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
    "name": "Northwind Insights",
    "status": "ACTIVE",
    "created_at": "2026-06-02T09:14:55Z",
    "workspaces": [
      {
        "id": "9a8c4e70-1b2f-4d63-a5e8-77c0d31b9f24",
        "name": "Default",
        "is_default": true
      }
    ]
  }
}
Errors
StatusCodeWhen
404not_foundThe identifier is unknown, or is mapped to a different provider. Foreign mappings are never distinguishable from missing ones, so the route cannot be used to probe another partner's estate.
400validation_errorThe organization claim in the assertion does not match the `{external_org_id}` in the path. The path segment is never authoritative on its own.
Update or deactivate an organization #
PATCH /v2/organizations/{external_org_id}/ planned
Scope organizations:write Credentials: partner

Updates an organization's display name, or deactivates it when a client relationship ends. Only the fields you send are changed.

Path parameters
NameTypeDescription
external_org_idstringYour identifier for the organization to update.
Request body

Any subset of the mutable fields.

FieldTypeDescription
name string New display name for the organization.
status enum Set to DEACTIVATED to shut the organization down, or ACTIVE to restore it.
{
  "status": "DEACTIVATED"
}
Response

Single-object envelope carrying the organization after the update, in the same shape the detail endpoint returns.

FieldTypeDescription
data.external_org_idstringYour own identifier for this organization — the value you address it by everywhere in the API.
data.organization_iduuidOutset's identifier for the organization.
data.namestringDisplay name of the organization.
data.statusenumWhether the organization is active or has been deactivated. Deactivation semantics are still being designed, so the exact set of values and their effects may change before general availability.
data.created_atdatetimeWhen the organization was created in Outset (ISO 8601, UTC).
data.workspaces[].iduuidA workspace inside this organization. The list is read-only.
data.workspaces[].namestringName of that workspace.
data.workspaces[].is_defaultbooleanWhether this is the organization's default workspace, created automatically with it.
{
  "data": {
    "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
    "organization_id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
    "name": "Northwind Insights",
    "status": "DEACTIVATED",
    "created_at": "2026-06-02T09:14:55Z",
    "workspaces": [
      {
        "id": "9a8c4e70-1b2f-4d63-a5e8-77c0d31b9f24",
        "name": "Default",
        "is_default": true
      }
    ]
  }
}

Deactivation semantics are still being designed — what happens to the organization's in-flight studies, active recruitment, and its users' sessions is not yet settled, and this field ships with that decision. Treat it as ending the relationship rather than as a soft delete, and expect the exact behavior to be pinned down before general availability.

Usage aggregate across the estate #
GET /v2/organizations/usage/ planned
Scope usage:read Credentials: partner

One row per organization in your estate for a date range — studies, completed interviews, and recruitment spend. This is the invoicing aggregate: it answers "what did each client cost this month" in one call instead of 337.

Query parameters
NameTypeDescription
start_date required date (YYYY-MM-DD) First day of the reporting window, inclusive, in UTC.
end_date required date (YYYY-MM-DD) Last day of the reporting window, inclusive, in UTC.
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list, one row per organization.

FieldTypeDescription
data[].external_org_idstringYour identifier for the organization the row covers.
data[].organization_iduuidOutset's identifier for that organization.
data[].studies_createdintegerStudies created in the organization during the date range (excludes templates).
data[].interview_completes_rawintegerCompleted interviews in the date range, including low-quality ones (excludes fraudulent and archived interviews).
data[].interview_completes_validintegerThe high-quality subset of interview_completes_raw — low-quality interviews excluded.
data[].total_interview_duration_secondsfloatTotal recorded duration, in seconds, of the valid completed interviews.
data[].recruitment_spend_usdstring (decimal)Recruitment spend charged against the funding balance for this organization in the date range, in USD.
{
  "data": [
    {
      "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
      "organization_id": "b21f7c48-9a3e-4c65-8f0d-1e6a4b93c775",
      "studies_created": 5,
      "interview_completes_raw": 214,
      "interview_completes_valid": 197,
      "total_interview_duration_seconds": 148320.5,
      "recruitment_spend_usd": "1820.00"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Partner-only and estate-scoped — the assertion must carry no organization claim, and the route is meaningless to a single-organization caller. Deliberately narrower than /v2/usage/: engagement-session and login detail are not here, because they come from an analytics backend that cannot be fanned out across a whole estate. For those, call the per-organization usage routes with an organization claim set.

List members of an organization #
GET /v2/users/ planned
Scope users:read Credentials: user-delegated, partner

Lists the members of the organization your credential is pinned to — the token's organization, or the organization named in your assertion — with the role each holds.

Query parameters
NameTypeDescription
page_size integer · default 50 Rows per page, capped at 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of memberships, newest first.

FieldTypeDescription
data[].user_iduuidOutset identifier of the user.
data[].emailemailEmail address of the member.
data[].namestringFull display name of the member.
data[].roleenumThe member's role in this organization: ADMIN, MANAGER, USER, PWDR_USER, OBSERVER, or NON_RESEARCHER_ADMIN. PWDR_USER is a User whose study spend and launches are subject to per-study budget caps and approval configured by the organization.
data[].is_activebooleanWhether the member currently has access to the organization.
data[].created_atdatetimeWhen the membership was created (ISO 8601, UTC).
{
  "data": [
    {
      "user_id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
      "email": "researcher@example.com",
      "name": "Dana Okonjo",
      "role": "USER",
      "is_active": true,
      "created_at": "2026-06-02T09:20:31Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Organization-scoped: a user-delegated token resolves the organization it is pinned to, and a partner assertion must carry an organization claim — a missing claim is a 400 rather than a listing of every user in the estate. Outset staff accounts attached to a customer organization for support are excluded from this listing and from every write below — a partner credential can neither see nor modify them.

There is deliberately no create-user endpoint. Members are provisioned just-in-time at first OIDC login, so creating a user here would race that flow and create a second account.

Read one member #
GET /v2/users/{user_id}/ planned
Scope users:read Credentials: user-delegated, partner

Reads one member of the organization your credential is pinned to — the token's organization, or the organization named in your assertion.

Path parameters
NameTypeDescription
user_iduuidOutset identifier of the user.
Response

Single-object envelope; same shape as a row from the list endpoint.

FieldTypeDescription
data.user_iduuidOutset identifier of the user.
data.emailemailEmail address of the member.
data.namestringFull display name of the member.
data.roleenumThe member's role in this organization: ADMIN, MANAGER, USER, PWDR_USER, OBSERVER, or NON_RESEARCHER_ADMIN. PWDR_USER is a User whose study spend and launches are subject to per-study budget caps and approval configured by the organization.
data.is_activebooleanWhether the member currently has access to the organization.
data.created_atdatetimeWhen the membership was created (ISO 8601, UTC).
{
  "data": {
    "user_id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
    "email": "researcher@example.com",
    "name": "Dana Okonjo",
    "role": "USER",
    "is_active": true,
    "created_at": "2026-06-02T09:20:31Z"
  }
}
Errors
StatusCodeWhen
404not_foundThe user is not a member of the claimed organization, or is an Outset staff account. Both are indistinguishable from a nonexistent user.
Set a member's role #
PATCH /v2/users/{user_id}/ planned
Scope users:write Credentials: user-delegated, partner

Changes a member's role within the organization your credential is pinned to — the token's organization, or the organization named in your assertion.

Path parameters
NameTypeDescription
user_iduuidOutset identifier of the user.
Request body

The role to set.

FieldTypeDescription
role required enum New role for this member: ADMIN, MANAGER, USER, PWDR_USER, OBSERVER, or NON_RESEARCHER_ADMIN.
{
  "role": "ADMIN"
}
Response

Single-object envelope carrying the membership after the change, in the same shape the detail endpoint returns.

FieldTypeDescription
data.user_iduuidOutset identifier of the user.
data.emailemailEmail address of the member.
data.namestringFull display name of the member.
data.roleenumThe member's role in this organization: ADMIN, MANAGER, USER, PWDR_USER, OBSERVER, or NON_RESEARCHER_ADMIN. PWDR_USER is a User whose study spend and launches are subject to per-study budget caps and approval configured by the organization.
data.is_activebooleanWhether the member currently has access to the organization.
data.created_atdatetimeWhen the membership was created (ISO 8601, UTC).
{
  "data": {
    "user_id": "6f1c2b9a-3d54-4a11-9c0e-2b7d51a4e8f3",
    "email": "researcher@example.com",
    "name": "Dana Okonjo",
    "role": "ADMIN",
    "is_active": true,
    "created_at": "2026-06-02T09:20:31Z"
  }
}

Roles carry real authority: ADMIN, MANAGER, and USER can edit studies and launch recruitment against the organization's spend cap; PWDR_USER does the same under per-study budget caps and the approval the organization configures; OBSERVER is read-only; NON_RESEARCHER_ADMIN administers the organization but is walled off from participant output such as transcripts and reports. The panel-partner role is not settable here — it belongs to external recruitment vendors, not to a client organization's members.

Remove a member from an organization #
DELETE /v2/users/{user_id}/ planned
Scope users:write Credentials: user-delegated, partner

Removes a member's access to the organization your credential is pinned to — the token's organization, or the organization named in your assertion.

Path parameters
NameTypeDescription
user_iduuidOutset identifier of the user to deprovision from this organization.
Response

204 — empty body. The membership is gone; reading the user on this organization afterwards returns 404.

Organization-scoped, not a global account deletion: the person keeps their Outset account and any membership in your other organizations, and the studies and interviews they created stay where they are. A subsequent OIDC login carrying this organization would provision them again — revoke the assignment in your identity provider too, or the removal is temporary.

Read an organization's spend cap #
GET /v2/budget/ planned
Scope budget:read Credentials: partner

Reads the recruitment spend cap for the organization named in your assertion, together with what has been spent against it and what remains.

Response

Single-object envelope.

FieldTypeDescription
data.external_org_idstringYour identifier for the organization the cap applies to.
data.cap_usdstring (decimal), nullableMaximum recruitment spend allowed for this organization in USD, or null for no cap.
data.spent_usdstring (decimal)Recruitment spend already charged against the cap, in USD.
data.remaining_usdstring (decimal), nullableCap minus spend, in USD; null when there is no cap.
data.as_ofdatetimeWhen these figures were computed (ISO 8601, UTC).
{
  "data": {
    "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
    "cap_usd": "5000.00",
    "spent_usd": "1820.00",
    "remaining_usd": "3180.00",
    "as_of": "2026-08-11T14:07:44Z"
  }
}

A cap is not a balance. One funding organization holds the actual recruitment credit — readable at /v2/recruitment-wallet/ by naming that organization — and every client organization draws against it up to its own cap. There is no transfer between organizations and no per-organization balance to strand. A launch is therefore blocked when either the cap is exhausted or the funding balance is, and a FUNDS_REQUIRED webhook fires naming the organization, the study, and the amount needed.

Set an organization's spend cap #
PATCH /v2/budget/ planned
Scope budget:write Credentials: partner

Sets or clears the recruitment spend cap for the organization named in your assertion.

Request body

The new cap.

FieldTypeDescription
cap_usd required string (decimal), nullable New maximum recruitment spend for this organization in USD; send null to remove the cap.
{
  "cap_usd": "7500.00"
}
Response

Single-object envelope carrying the budget after the change, in the same shape the read endpoint returns.

FieldTypeDescription
data.external_org_idstringYour identifier for the organization the cap applies to.
data.cap_usdstring (decimal), nullableMaximum recruitment spend allowed for this organization in USD, or null for no cap.
data.spent_usdstring (decimal)Recruitment spend already charged against the cap, in USD.
data.remaining_usdstring (decimal), nullableCap minus spend, in USD; null when there is no cap.
data.as_ofdatetimeWhen these figures were computed (ISO 8601, UTC).
{
  "data": {
    "external_org_id": "3c9e5d21-77b4-4f8a-b6d2-0a15e9c3f480",
    "cap_usd": "7500.00",
    "spent_usd": "1820.00",
    "remaining_usd": "5680.00",
    "as_of": "2026-08-11T14:09:12Z"
  }
}

Raising a cap commits real money — spend resolves against the shared funding balance, so a higher cap in one organization can exhaust the balance the whole estate draws on. Lowering a cap below what has already been spent does not claw anything back; it only blocks further recruitment. Every change is recorded against the credential that made it.

Chapter 05

Workspaces & Projects #

Workspaces and projects are the two containers above a study: a workspace partitions an organization's work and governs who can see it, and a project groups the studies that answer one research question. Every study, interview, and report you reach elsewhere in this document hangs off a project, so these endpoints are usually the first call an integration makes — resolve a workspace, create or find a project, then create studies inside it. Reads need projects:read; because there is no projects:write scope, project writes reuse studies:write.

List workspaces #
GET /v2/workspaces/ planned
Scope projects:read Credentials: user-delegated, partner

Lists the workspaces the credential can reach — for a user-delegated token, the intersection of the workspaces granted at consent time with the caller's current membership; for a partner assertion, the non-archived workspaces of the organization named in the assertion. Use this to pick the workspace a new project should live in.

Query parameters
NameTypeDescription
page_size integer · default 50 Number of workspaces per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of workspaces, newest-first.

FieldTypeDescription
data[].iduuidWorkspace identifier, used as workspace_id when creating a project.
data[].namestringDisplay name of the workspace.
data[].is_defaultbooleanWhether this is the organization's default workspace, which cannot be renamed or archived.
data[].created_atdatetimeWhen the workspace was created.
{
  "data": [
    {
      "id": "8f2b6c14-3d5a-4f61-9b02-7c1e5a2d4f80",
      "name": "Brand & Comms",
      "is_default": false,
      "created_at": "2026-03-04T09:12:44Z"
    },
    {
      "id": "1c7d90ab-5e33-4a27-8f19-b6a0e4c22d51",
      "name": "General",
      "is_default": true,
      "created_at": "2025-11-19T15:40:02Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Read-only on purpose. Archived workspaces are omitted, and so are internal storage workspaces the product creates for itself — a workspace that does not appear here cannot be named in any other call. An empty list is a real answer, not an error: it means the credential's grant no longer intersects any live workspace, and every workspace-scoped read will be empty until that is fixed.

List projects #
GET /v2/projects/ proposed
Scope projects:read Credentials: user-delegated, partner

Lists projects the credential can reach, most-recently-modified first, each with rolled-up study and interview counts. Filter by workspace to enumerate one part of an organization.

Query parameters
NameTypeDescription
workspace_id uuid Return only projects in this workspace; must be a workspace the credential can reach.
archived boolean · default False Whether to return archived projects instead of active ones.
page_size integer · default 50 Number of projects per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of projects, ordered by last modification, newest first.

FieldTypeDescription
data[].iduuidProject identifier.
data[].namestringDisplay name of the project.
data[].workspace_iduuidWorkspace the project belongs to.
data[].workspace_namestringDisplay name of that workspace.
data[].project_typestringHow the project's studies are moderated: AI_MODERATED or HUMAN_MODERATED.
data[].archivedbooleanWhether the project has been archived.
data[].study_countintegerNumber of studies in the project, of any status.
data[].completed_interview_countintegerNumber of completed, valid interviews across the project's studies.
data[].participant_response_countintegerNumber of analyzed participant responses across the project's reports.
data[].created_atdatetimeWhen the project was created.
data[].modified_atdatetimeWhen the project or anything the counts summarize last changed.
{
  "data": [
    {
      "id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
      "name": "Spring refresh — packaging concepts",
      "workspace_id": "8f2b6c14-3d5a-4f61-9b02-7c1e5a2d4f80",
      "workspace_name": "Brand & Comms",
      "project_type": "AI_MODERATED",
      "archived": false,
      "study_count": 3,
      "completed_interview_count": 214,
      "participant_response_count": 1908,
      "created_at": "2026-06-02T11:03:18Z",
      "modified_at": "2026-08-09T17:22:51Z"
    }
  ],
  "next_cursor": "cD0yMDI2LTA4LTA5VDE3OjIyOjUxWg",
  "has_more": true
}

The three counts are aggregated per page, which is why page_size is capped like every other list. Pulling a whole large estate one 200-project page at a time is expected and cheap; asking for the counts across thousands of projects in one call is not offered.

Create a project #
POST /v2/projects/ proposed
Scope studies:write Credentials: user-delegated, partner

Creates an empty project in a workspace. This is the container you then create studies in; nothing else about the project is configured here.

Request body

Name and target workspace.

FieldTypeDescription
name required string Display name for the project, up to 255 characters.
workspace_id required uuid Workspace to create the project in; must be one the credential can reach.
{
  "name": "Spring refresh — packaging concepts",
  "workspace_id": "8f2b6c14-3d5a-4f61-9b02-7c1e5a2d4f80"
}
Response

201 with the created project, in the same shape the detail read returns. Projects created through the API are always `AI_MODERATED`, and the rolled-up counts start at zero.

FieldTypeDescription
data.iduuidProject identifier.
data.namestringDisplay name of the project.
data.workspace_iduuidWorkspace the project belongs to.
data.workspace_namestringDisplay name of that workspace.
data.project_typestringHow the project's studies are moderated: AI_MODERATED or HUMAN_MODERATED.
data.archivedbooleanWhether the project has been archived.
data.research_goalsstringFree-text research goals for the project, used to steer analysis; empty when never set.
data.study_countintegerNumber of studies in the project, of any status.
data.completed_interview_countintegerNumber of completed, valid interviews across the project's studies.
data.participant_response_countintegerNumber of analyzed participant responses across the project's reports.
data.first_interview_datedatetimeWhen the project's first valid completed interview finished; null before any interview completes.
data.created_atdatetimeWhen the project was created.
data.modified_atdatetimeWhen the project was last modified.
{
  "data": {
    "id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
    "name": "Spring refresh — packaging concepts",
    "workspace_id": "8f2b6c14-3d5a-4f61-9b02-7c1e5a2d4f80",
    "workspace_name": "Brand & Comms",
    "project_type": "AI_MODERATED",
    "archived": false,
    "research_goals": "",
    "study_count": 0,
    "completed_interview_count": 0,
    "participant_response_count": 0,
    "first_interview_date": null,
    "created_at": "2026-06-02T11:03:18Z",
    "modified_at": "2026-06-02T11:03:18Z"
  }
}
Errors
StatusCodeWhen
404not_found`workspace_id` names a workspace outside the credential's reach, or one that does not exist. Unreachable resources are reported as missing wherever they are named, including in a request body.
409project_quota_exceededThe organization has reached the project limit for its subscription tier. Retrying will not help until the tier changes or a project is archived.

Project writes use studies:write rather than a projects:write scope, which does not exist — the same authority that lets you build studies lets you create the container they live in.

Read a project #
GET /v2/projects/{project_id}/ proposed
Scope projects:read Credentials: user-delegated, partner

Returns one project with its rolled-up counts and research goals.

Path parameters
NameTypeDescription
project_iduuidIdentifier of the project.
Response

The project.

FieldTypeDescription
data.iduuidProject identifier.
data.namestringDisplay name of the project.
data.workspace_iduuidWorkspace the project belongs to.
data.workspace_namestringDisplay name of that workspace.
data.project_typestringHow the project's studies are moderated: AI_MODERATED or HUMAN_MODERATED.
data.archivedbooleanWhether the project has been archived.
data.research_goalsstringFree-text research goals for the project, used to steer analysis; empty when never set.
data.study_countintegerNumber of studies in the project, of any status.
data.completed_interview_countintegerNumber of completed, valid interviews across the project's studies.
data.participant_response_countintegerNumber of analyzed participant responses across the project's reports.
data.first_interview_datedatetimeWhen the project's first valid completed interview finished; null before any interview completes.
data.created_atdatetimeWhen the project was created.
data.modified_atdatetimeWhen the project was last modified.
{
  "data": {
    "id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
    "name": "Spring refresh — packaging concepts",
    "workspace_id": "8f2b6c14-3d5a-4f61-9b02-7c1e5a2d4f80",
    "workspace_name": "Brand & Comms",
    "project_type": "AI_MODERATED",
    "archived": false,
    "research_goals": "Understand which packaging cues signal freshness to weekly grocery shoppers.",
    "study_count": 3,
    "completed_interview_count": 214,
    "participant_response_count": 1908,
    "first_interview_date": "2026-06-07T08:44:10Z",
    "created_at": "2026-06-02T11:03:18Z",
    "modified_at": "2026-08-09T17:22:51Z"
  }
}
Update a project #
PATCH /v2/projects/{project_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Renames a project, sets its research goals, or archives and restores it. A project cannot be moved between workspaces.

Path parameters
NameTypeDescription
project_iduuidIdentifier of the project.
Request body

Any subset of the editable fields; unknown fields are rejected.

FieldTypeDescription
name string New display name, up to 255 characters.
research_goals string Free-text research goals for the project, used to steer analysis.
archived boolean Set true to archive the project and hide it from active listings, false to restore it.
{
  "archived": true
}
Response

The project after the update, in the same shape the detail read returns.

FieldTypeDescription
data.iduuidProject identifier.
data.namestringDisplay name of the project.
data.workspace_iduuidWorkspace the project belongs to.
data.workspace_namestringDisplay name of that workspace.
data.project_typestringHow the project's studies are moderated: AI_MODERATED or HUMAN_MODERATED.
data.archivedbooleanWhether the project has been archived.
data.research_goalsstringFree-text research goals for the project, used to steer analysis; empty when never set.
data.study_countintegerNumber of studies in the project, of any status.
data.completed_interview_countintegerNumber of completed, valid interviews across the project's studies.
data.participant_response_countintegerNumber of analyzed participant responses across the project's reports.
data.first_interview_datedatetimeWhen the project's first valid completed interview finished; null before any interview completes.
data.created_atdatetimeWhen the project was created.
data.modified_atdatetimeWhen the project was last modified.
{
  "data": {
    "id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
    "name": "Spring refresh — packaging concepts",
    "workspace_id": "8f2b6c14-3d5a-4f61-9b02-7c1e5a2d4f80",
    "workspace_name": "Brand & Comms",
    "project_type": "AI_MODERATED",
    "archived": true,
    "research_goals": "Understand which packaging cues signal freshness to weekly grocery shoppers.",
    "study_count": 3,
    "completed_interview_count": 214,
    "participant_response_count": 1908,
    "first_interview_date": "2026-06-07T08:44:10Z",
    "created_at": "2026-06-02T11:03:18Z",
    "modified_at": "2026-08-11T10:14:02Z"
  }
}

Archiving is reversible and does not delete anything: the project's studies, interviews, and reports stay readable by ID and keep counting toward usage. There is no delete endpoint — archive is the intended way to retire a project.

Chapter 06

Studies & Lifecycle #

A study is one interview definition — its guide, its screener, its interviewer settings — and it lives inside a project. This group covers the whole lifecycle: creating a study and editing its settings, duplicating or copying content from an existing one, moving it between projects, running the pre-publish checks, and taking it live. Reading and editing a study needs studies:read / studies:write; anything that opens or closes the doors to participants — publish, unpublish, close — needs the separate studies:launch scope, because those operations reach production panels and can incur real fees. Study content (sections, questions, screener, stimuli) has its own chapters; the endpoints here treat the guide as a whole.

Create a study #
POST /v2/studies/ proposed
Scope studies:write Credentials: user-delegated, partner

Creates a study in a project. The study starts in DRAFT state with no questions — add content through the study-content endpoints, then publish it. A default interviewer voice is seeded so a programmatically created study is publishable without a further call.

Request body

The study's project and its initial overview fields. Everything except `name` and `project_id` is optional and can be set later with `PATCH /v2/studies/{study_id}/`.

FieldTypeDescription
name required string Display name for the study, shown to researchers in the project list.
project_id required uuid Project the study is created in; it must be reachable by your credential's workspace grant.
interview_method enum How participants take the interview: `CHAT` (text), `VOICE` (audio), `INTERACTIVE` (voice-to-voice), `VIDEO`, `SCREENSHARE`, `MOB_UX_WEB` (mobile UX on mobile web), or `MOB_UX_APP` (mobile UX in an app); an organization may have some methods restricted.
context string Background the AI interviewer should know about the study's purpose and audience (10,000 characters, or 50,000 for organizations with the expanded context limit); participants may infer it, so keep confidential details out.
goals array[string] Research objectives, one concise sentence per entry; the combined text of all goals must stay under 1,000 characters.
language_code string ISO 639-1 code of the language the interview is conducted in.
welcome_message string Message shown to participants before the interview starts.
end_message string Thank-you message shown after participants finish.
{
  "name": "Q3 Checkout Friction",
  "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
  "interview_method": "VOICE",
  "context": "We redesigned the checkout flow in June. We want to understand where shoppers hesitate.",
  "goals": [
    "Find the steps where shoppers abandon checkout",
    "Understand how shoppers feel about the new address form"
  ],
  "language_code": "en",
  "welcome_message": "Thanks for joining — this should take about 15 minutes."
}
Response

The created study, in the single-object envelope.

FieldTypeDescription
data.iduuidID of the new study; use it on every other endpoint in this group.
data.namestringDisplay name of the study.
data.project_iduuidProject the study was created in.
data.project_namestringName of that project.
data.interview_methodenumInterview method the study was created with.
data.moderation_typeenumAlways `AI_MODERATED` for studies created through the API.
data.language_codestringISO 639-1 code of the interview language.
data.goalsarray[string]Research objectives as stored, or `null` when none were supplied.
data.stateenumLifecycle state — always `DRAFT` on a freshly created study.
data.urlurlDeep link to the study in the Outset web app, for handing a researcher the editor.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "name": "Q3 Checkout Friction",
    "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
    "project_name": "Checkout Research",
    "interview_method": "VOICE",
    "moderation_type": "AI_MODERATED",
    "language_code": "en",
    "goals": [
      "Find the steps where shoppers abandon checkout",
      "Understand how shoppers feel about the new address form"
    ],
    "state": "DRAFT",
    "url": "https://app.outset.ai/project/3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1/survey/9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42"
  }
}
Errors
StatusCodeWhen
403feature_not_enabledThe organization is not entitled to the requested `interview_method` (commonly `INTERACTIVE` or `VIDEO`).
400validation_errorThe subscription's study quota is exhausted, or `context` exceeds the organization's character limit.

Studies created through the API are always AI-moderated. Human-moderated studies need researcher-uploaded interview recordings and are authored in the web app.

List studies #
GET /v2/studies/ planned
Scope studies:read Credentials: user-delegated, partner

Lists the studies your credential can reach, newest first, with optional filters by project, workspace, active state, name, and creation window. This is the audit read: with include_content=true each row carries the study's full question and screener definition.

Query parameters
NameTypeDescription
project_id uuid Return only studies in this project.
workspace_id uuid Return only studies whose project belongs to this workspace.
active boolean Filter on whether the study is currently accepting participants.
state string Filter by lifecycle state: DRAFT, LIVE, PAUSED, or CLOSED. The boolean `active` filter is the LIVE shorthand; use one or the other.
search string Case-insensitive substring match on the study name.
created_after datetime Only studies created at or after this ISO 8601 timestamp.
created_before datetime Only studies created strictly before this ISO 8601 timestamp.
include_content boolean · default False Inline each study's questions and screener definition in the row, as the study-audit read does; off by default because it makes pages substantially larger.
page_size integer · default 50 Rows per page, maximum 200.
cursor string Opaque cursor from the previous page's `next_cursor`.
Response

Cursor-paginated list of studies.

FieldTypeDescription
data[].iduuidStudy ID.
data[].namestringDisplay name of the study.
data[].project_iduuidProject the study belongs to.
data[].stateenumLifecycle state: `DRAFT` (never published), `LIVE` (accepting participants), `PAUSED` (published before, currently not accepting), or `CLOSED` (terminally closed).
data[].activebooleanWhether the study is accepting new participants right now.
data[].interview_methodenumHow participants take the interview.
data[].language_codestringISO 639-1 code of the interview language.
data[].question_countintegerNumber of guide questions in the study.
data[].has_screenerbooleanWhether the study has a screener attached.
data[].visual_intelligence_enabledbooleanWhether AI analysis of participant recordings has been approved for this study.
data[].closed_atdatetimeWhen the study was closed, or `null` if it is not closed.
data[].closed_methodenumHow it closed — `MANUAL`, `AUTO_INACTIVITY_30D`, or `AUTO_HARD_LIMIT_90D` — or `null` if it is not closed.
data[].auto_close_atdatetimeWhen the organization's auto-close policy will close this study, or `null` when no auto-close is pending.
data[].auto_close_methodenumWhich policy would trigger that auto-close (inactivity or hard limit), or `null`.
data[].auto_close_enabledbooleanPer-study opt-out: when false the study is never auto-closed and `auto_close_at` is always `null`.
data[].createddatetimeWhen the study was created (ISO 8601, UTC).
data[].modifieddatetimeWhen the study was last edited (ISO 8601, UTC).
data[].questionsarray[object]Full guide-question definitions; present only when `include_content=true`.
data[].screenerobjectFull screener definition, or `null` when the study has none; present only when `include_content=true`.
{
  "data": [
    {
      "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
      "name": "Q3 Checkout Friction",
      "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
      "state": "LIVE",
      "active": true,
      "interview_method": "VOICE",
      "language_code": "en",
      "question_count": 12,
      "has_screener": true,
      "visual_intelligence_enabled": false,
      "closed_at": null,
      "closed_method": null,
      "auto_close_at": "2026-09-14T09:12:00Z",
      "auto_close_method": "AUTO_INACTIVITY_30D",
      "auto_close_enabled": true,
      "created": "2026-08-01T14:22:03Z",
      "modified": "2026-08-11T08:40:55Z"
    }
  ],
  "next_cursor": "cD0yMDI2LTA4LTAxVDE0OjIyOjAzWg%3D%3D",
  "has_more": true
}

Studies that are not assigned to a project, and studies outside your credential's workspace grant, never appear here.

Read a study's full definition #
GET /v2/studies/{study_id}/ planned
Scope studies:read Credentials: user-delegated, partner

Returns the complete definition of one study: its overview and interviewer settings, its sections and questions in order, and its screener. This is the canonical read to ground any edit — fetch it before changing content, reordering, or editing conditions.

Path parameters
NameTypeDescription
study_iduuidID of the study to read.
Response

The study definition, in the single-object envelope.

FieldTypeDescription
data.iduuidStudy ID.
data.namestringDisplay name of the study.
data.project_iduuidProject the study belongs to.
data.stateenumLifecycle state: `DRAFT`, `LIVE`, `PAUSED`, or `CLOSED`.
data.activebooleanWhether the study is accepting new participants right now.
data.interview_methodenumHow participants take the interview.
data.moderation_typeenumWhether the interview is AI-moderated or human-moderated.
data.contextstringBackground the AI interviewer is given about the study.
data.goalsarray[string]Research objectives for the study, or `null` when none are set.
data.language_codestringISO 639-1 code of the interview language.
data.base_language_codestringThe participant-facing default language and the language recruitment copy is written in.
data.welcome_messagestringMessage shown to participants before the interview starts.
data.end_messagestringMessage shown after participants finish.
data.completion_urlurlWhere participants are redirected after finishing, for self-managed external panels; empty when Outset handles the redirect.
data.max_interviewsintegerCap on completed interviews for this study.
data.interviewer_personaenumBuilt-in interviewer style: `HIGH_TWO_FOLLOW_UP` ("Standard"), `LOW_TWO_FOLLOW_UP` ("Neutral"), or `NONE`.
data.custom_interviewer_persona_iduuidTrained custom persona assigned to the study, or `null` when a built-in persona is used.
data.interviewer_voice_engineenum`SIMPLE` uses the platform default voice for the language; `ADVANCED` uses a voice picked from the voice catalog.
data.interviewer_voice_idstringCatalog voice in use, or empty when the engine is `SIMPLE`.
data.flag_piibooleanWhether participant messages are scanned for personal identifiers and flagged for review.
data.pii_flag_settingsarray[object]Effective per-category PII settings, each with `category`, `enabled`, `review_mode`, and `locked` (true when the organization enforces the value).
data.visual_intelligence_enabledbooleanWhether AI analysis of participant recordings has been approved for this study.
data.auto_close_enabledbooleanWhether the organization's auto-close policies may close this study.
data.total_questionsintegerNumber of guide questions, excluding matrix sub-rows.
data.createddatetimeWhen the study was created (ISO 8601, UTC).
data.modifieddatetimeWhen the study was last edited (ISO 8601, UTC).
data.sectionsarray[object]Sections in display order, each with `id`, `name`, `type`, `position`, and its questions with full configuration.
data.screenerobjectThe screener definition with its questions, options, and qualification rules, or `null` when the study has none.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "name": "Q3 Checkout Friction",
    "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
    "state": "LIVE",
    "active": true,
    "interview_method": "VOICE",
    "language_code": "en",
    "max_interviews": 120,
    "interviewer_voice_engine": "ADVANCED",
    "interviewer_voice_id": "b1d9f0c4-77a2-4e6b-9a31-2c5d8e4f1a09",
    "total_questions": 12,
    "…": "remaining overview and interviewer settings omitted — see the field table above",
    "sections": [
      {
        "id": "5b2e7d10-9a44-4c8f-8e21-7d3f6a0b9c55",
        "name": "Warm-up",
        "type": "STANDARD",
        "position": 0,
        "questions": [
          {
            "id": "a7c30f18-4d92-4b6a-9f57-0e1b8c2d4a63",
            "position": 0,
            "text": "Walk me through the last time you checked out on our site.",
            "question_type": "TEXT"
          }
        ]
      }
    ],
    "screener": {
      "id": "d4a81c07-2f36-4c95-b8e0-6a9f3d7b1e28",
      "questions": []
    },
    "created": "2026-08-01T14:22:03Z",
    "modified": "2026-08-11T08:40:55Z"
  }
}
Update a study's settings #
PATCH /v2/studies/{study_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Updates the study's overview and interviewer settings. Only the fields you send are changed; sending null for a nullable field clears it. Study content (sections, questions, screener) is edited through the study-content endpoints, not here.

Path parameters
NameTypeDescription
study_iduuidID of the study to update.
Request body

Any subset of the study's settings. Unknown fields are rejected.

FieldTypeDescription
name string Display name of the study.
interview_method enum `CHAT`, `VOICE`, `INTERACTIVE`, `VIDEO`, `SCREENSHARE`, `MOB_UX_WEB`, or `MOB_UX_APP`; switching to a method the organization has restricted is rejected.
context string Background for the AI interviewer (10,000 characters, or 50,000 with the expanded limit); per-section and per-question detail belongs on those objects instead.
goals array[string] Research objectives, combined text under 1,000 characters; send `null` to remove all goals.
language_code string ISO 639-1 code of the interview language; send `null` to fall back to the default.
base_language_code string Participant-facing default language, which must be one of the study's offered languages; send `null` to clear it.
welcome_message string Message shown to participants before the interview starts.
end_message string Message shown after participants finish.
completion_url url Redirect target after completion for a self-managed external panel; may contain `{placeholder}` tokens filled from the participant's start parameters. Leave unset when recruiting through Outset.
max_interviews integer Cap on completed interviews; must be at least 1, at least the active recruitment target, and at least the number of interviews already completed or in progress.
is_template boolean Mark the study as a reusable template, or convert it back to a normal study.
auto_close_enabled boolean Whether the organization's inactivity and maximum-duration policies may close this study automatically.
flag_pii boolean Scan participant messages for personal identifiers and flag them for review; shorthand for the `PERSONAL_IDENTIFIERS` entry of `pii_flag_settings` and not changeable once interviews have started.
pii_flag_settings array[object] Per-category PII configuration; each entry sets `category` (`PERSONAL_IDENTIFIERS` or `SENSITIVE_PERSONAL_DETAILS`) plus `enabled` and/or `review_mode` (`MANUAL_REVIEW` or `DELETE_ONLY`).
pass_metadata_to_llm boolean Let the AI interviewer reference the participant's start parameters and screener answers during the conversation.
skip_walkthrough boolean Skip the participant onboarding screens at the start of the interview.
skip_app_download boolean For mobile-app studies, assume the app is already installed and skip the download step.
mobile_only boolean Restrict text, voice, and video interviews to mobile devices; desktop participants get a hand-off screen with a link and QR code.
allow_fallback_to_chat boolean Let voice and voice-to-voice interviews fall back to text chat when the participant's device or network can't sustain the method.
enforce_no_video_for_screenshare boolean For screenshare studies, force participant video off even where the organization would allow it.
interviewer_voice_output_enabled boolean Speak the interviewer's messages aloud; required for the `INTERACTIVE` method.
interviewer_voice_defaults_on boolean Start the interviewer's voice unmuted instead of muted-until-clicked; has no effect unless voice output is enabled.
interviewer_persona enum Built-in interviewer style — `HIGH_TWO_FOLLOW_UP` ("Standard"), `LOW_TWO_FOLLOW_UP` ("Neutral", no upbeat acknowledgements), or `NONE`; setting a non-`NONE` value clears any custom persona.
custom_interviewer_persona_id uuid Trained custom persona to assign, from `GET /v2/interviewer-personas/`; send `null` to fall back to the built-in persona.
interviewer_voice_engine enum `SIMPLE` for the platform default voice per language, or `ADVANCED` to pick a voice from the catalog; voice-to-voice studies are always coerced to `SIMPLE`.
interviewer_voice_id string Voice for the `ADVANCED` engine, from `GET /v2/interviewer-voices/`; send `null` to fall back to the engine's default voice.
filter_low_quality boolean End interviews whose answers fall below the platform's standard quality threshold.
medical_transcription boolean Transcribe with a clinical speech-to-text engine; available for English-language studies only.
self_recruit_incentive_enabled boolean Pay completed participants of a self-recruited study a gift card; enabling it ensures an email-collecting screener question so payouts have a recipient.
self_recruit_incentive_amount_usd number Per-participant incentive amount in USD; publishing is blocked while incentives are enabled with a zero amount.
{
  "name": "Q3 Checkout Friction (voice)",
  "interviewer_voice_engine": "ADVANCED",
  "interviewer_voice_id": "b1d9f0c4-77a2-4e6b-9a31-2c5d8e4f1a09",
  "max_interviews": 150
}
Response

The updated study, in the same shape as `GET /v2/studies/{study_id}/` without the content graph.

FieldTypeDescription
data.iduuidStudy ID.
data.namestringDisplay name of the study.
data.project_iduuidProject the study belongs to.
data.stateenumLifecycle state: `DRAFT`, `LIVE`, `PAUSED`, or `CLOSED`.
data.activebooleanWhether the study is accepting new participants right now.
data.interview_methodenumHow participants take the interview.
data.moderation_typeenumWhether the interview is AI-moderated or human-moderated.
data.contextstringBackground the AI interviewer is given about the study.
data.goalsarray[string]Research objectives for the study, or `null` when none are set.
data.language_codestringISO 639-1 code of the interview language.
data.base_language_codestringThe participant-facing default language and the language recruitment copy is written in.
data.welcome_messagestringMessage shown to participants before the interview starts.
data.end_messagestringMessage shown after participants finish.
data.completion_urlurlWhere participants are redirected after finishing, for self-managed external panels; empty when Outset handles the redirect.
data.max_interviewsintegerCap on completed interviews for this study.
data.interviewer_personaenumBuilt-in interviewer style: `HIGH_TWO_FOLLOW_UP` ("Standard"), `LOW_TWO_FOLLOW_UP` ("Neutral"), or `NONE`.
data.custom_interviewer_persona_iduuidTrained custom persona assigned to the study, or `null` when a built-in persona is used.
data.interviewer_voice_engineenum`SIMPLE` uses the platform default voice for the language; `ADVANCED` uses a voice picked from the voice catalog.
data.interviewer_voice_idstringCatalog voice in use, or empty when the engine is `SIMPLE`.
data.flag_piibooleanWhether participant messages are scanned for personal identifiers and flagged for review.
data.pii_flag_settingsarray[object]Effective per-category PII settings, each with `category`, `enabled`, `review_mode`, and `locked` (true when the organization enforces the value).
data.visual_intelligence_enabledbooleanWhether AI analysis of participant recordings has been approved for this study.
data.auto_close_enabledbooleanWhether the organization's auto-close policies may close this study.
data.total_questionsintegerNumber of guide questions, excluding matrix sub-rows.
data.createddatetimeWhen the study was created (ISO 8601, UTC).
data.modifieddatetimeWhen the study was last edited (ISO 8601, UTC).
data.changed_fieldsarray[string]Names of the fields this request actually changed, so a no-op update is distinguishable from an applied one.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "name": "Q3 Checkout Friction (voice)",
    "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
    "state": "LIVE",
    "interview_method": "VOICE",
    "max_interviews": 150,
    "interviewer_voice_engine": "ADVANCED",
    "interviewer_voice_id": "b1d9f0c4-77a2-4e6b-9a31-2c5d8e4f1a09",
    "…": "remaining overview and interviewer settings omitted — see the field table above",
    "modified": "2026-08-12T09:03:41Z",
    "changed_fields": [
      "name",
      "max_interviews",
      "interviewer_voice_id"
    ]
  }
}
Errors
StatusCodeWhen
400validation_error`max_interviews` drops below the completed-plus-in-progress interview count or the active recruitment target, `flag_pii` is toggled after interviews have started, or `base_language_code` names a language the study does not offer.

interviewer_voice_id is cleared when you flip interviewer_voice_engine without setting a new voice in the same request — voice IDs are not portable between engines.

Duplicate a study #
POST /v2/studies/{study_id}/duplicate/ proposed
Scope studies:write Credentials: user-delegated, partner

Creates a full copy of the study — sections, questions, screener, and routing logic — in the same project. The copy is created inactive with recruitment links cleared, so duplicating can never start a parallel recruitment stream.

Path parameters
NameTypeDescription
study_iduuidID of the study to copy.
Request body

Optional overrides for the copy.

FieldTypeDescription
name string Name for the copy; defaults to the original's name followed by "(copy)".
duplicate_skip_conditions boolean Copy the study's skip and routing logic along with the questions; set false to copy the questions and screener without it.
{
  "name": "Q3 Checkout Friction — Spanish pilot"
}
Response

The new study, in the same shape as `GET /v2/studies/{study_id}/` without the content graph.

FieldTypeDescription
data.iduuidStudy ID.
data.namestringDisplay name of the study.
data.project_iduuidProject the study belongs to.
data.stateenumLifecycle state: `DRAFT`, `LIVE`, `PAUSED`, or `CLOSED`.
data.activebooleanWhether the study is accepting new participants right now.
data.interview_methodenumHow participants take the interview.
data.moderation_typeenumWhether the interview is AI-moderated or human-moderated.
data.contextstringBackground the AI interviewer is given about the study.
data.goalsarray[string]Research objectives for the study, or `null` when none are set.
data.language_codestringISO 639-1 code of the interview language.
data.base_language_codestringThe participant-facing default language and the language recruitment copy is written in.
data.welcome_messagestringMessage shown to participants before the interview starts.
data.end_messagestringMessage shown after participants finish.
data.completion_urlurlWhere participants are redirected after finishing, for self-managed external panels; empty when Outset handles the redirect.
data.max_interviewsintegerCap on completed interviews for this study.
data.interviewer_personaenumBuilt-in interviewer style: `HIGH_TWO_FOLLOW_UP` ("Standard"), `LOW_TWO_FOLLOW_UP` ("Neutral"), or `NONE`.
data.custom_interviewer_persona_iduuidTrained custom persona assigned to the study, or `null` when a built-in persona is used.
data.interviewer_voice_engineenum`SIMPLE` uses the platform default voice for the language; `ADVANCED` uses a voice picked from the voice catalog.
data.interviewer_voice_idstringCatalog voice in use, or empty when the engine is `SIMPLE`.
data.flag_piibooleanWhether participant messages are scanned for personal identifiers and flagged for review.
data.pii_flag_settingsarray[object]Effective per-category PII settings, each with `category`, `enabled`, `review_mode`, and `locked` (true when the organization enforces the value).
data.visual_intelligence_enabledbooleanWhether AI analysis of participant recordings has been approved for this study.
data.auto_close_enabledbooleanWhether the organization's auto-close policies may close this study.
data.total_questionsintegerNumber of guide questions, excluding matrix sub-rows.
data.createddatetimeWhen the study was created (ISO 8601, UTC).
data.modifieddatetimeWhen the study was last edited (ISO 8601, UTC).
data.source_iduuidID of the study that was copied.
data.urlurlDeep link to the copy in the Outset web app.
{
  "data": {
    "id": "7f21b8d6-3a55-49c0-9e14-c8b6f0a27d13",
    "name": "Q3 Checkout Friction — Spanish pilot",
    "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
    "state": "DRAFT",
    "active": false,
    "interview_method": "VOICE",
    "language_code": "en",
    "total_questions": 12,
    "…": "remaining overview and interviewer settings omitted — see the field table above",
    "created": "2026-08-12T09:14:22Z",
    "modified": "2026-08-12T09:14:22Z",
    "source_id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "url": "https://app.outset.ai/project/3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1/survey/7f21b8d6-3a55-49c0-9e14-c8b6f0a27d13"
  }
}
Copy content from another study #
POST /v2/studies/{study_id}/copy-content/ proposed
Scope studies:write Credentials: user-delegated, partner

Replaces this study's sections, questions, and screener with those of another study you can reach. The target study's own overview — name, context, goals, interviewer settings — is preserved.

Path parameters
NameTypeDescription
study_iduuidID of the study that receives the content.
Request body

The study to copy from.

FieldTypeDescription
source_study_id required uuid ID of the study whose sections, questions, and screener are copied; it must be in the same organization and reachable by your workspace grant.
{
  "source_study_id": "e2b6a03d-8c14-4f77-90ab-1d5c7e93f204"
}
Response

The receiving study after the copy, in the same shape as `GET /v2/studies/{study_id}/` without the content graph, plus what was written into it.

FieldTypeDescription
data.iduuidStudy ID.
data.namestringDisplay name of the study.
data.project_iduuidProject the study belongs to.
data.stateenumLifecycle state: `DRAFT`, `LIVE`, `PAUSED`, or `CLOSED`.
data.activebooleanWhether the study is accepting new participants right now.
data.interview_methodenumHow participants take the interview.
data.moderation_typeenumWhether the interview is AI-moderated or human-moderated.
data.contextstringBackground the AI interviewer is given about the study.
data.goalsarray[string]Research objectives for the study, or `null` when none are set.
data.language_codestringISO 639-1 code of the interview language.
data.base_language_codestringThe participant-facing default language and the language recruitment copy is written in.
data.welcome_messagestringMessage shown to participants before the interview starts.
data.end_messagestringMessage shown after participants finish.
data.completion_urlurlWhere participants are redirected after finishing, for self-managed external panels; empty when Outset handles the redirect.
data.max_interviewsintegerCap on completed interviews for this study.
data.interviewer_personaenumBuilt-in interviewer style: `HIGH_TWO_FOLLOW_UP` ("Standard"), `LOW_TWO_FOLLOW_UP` ("Neutral"), or `NONE`.
data.custom_interviewer_persona_iduuidTrained custom persona assigned to the study, or `null` when a built-in persona is used.
data.interviewer_voice_engineenum`SIMPLE` uses the platform default voice for the language; `ADVANCED` uses a voice picked from the voice catalog.
data.interviewer_voice_idstringCatalog voice in use, or empty when the engine is `SIMPLE`.
data.flag_piibooleanWhether participant messages are scanned for personal identifiers and flagged for review.
data.pii_flag_settingsarray[object]Effective per-category PII settings, each with `category`, `enabled`, `review_mode`, and `locked` (true when the organization enforces the value).
data.visual_intelligence_enabledbooleanWhether AI analysis of participant recordings has been approved for this study.
data.auto_close_enabledbooleanWhether the organization's auto-close policies may close this study.
data.total_questionsintegerNumber of guide questions, excluding matrix sub-rows.
data.createddatetimeWhen the study was created (ISO 8601, UTC).
data.modifieddatetimeWhen the study was last edited (ISO 8601, UTC).
data.source_study_iduuidThe study the content came from.
data.source_study_namestringName of that study.
data.sections_copiedintegerNumber of sections written into the target study.
data.questions_copiedintegerNumber of questions written into the target study.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "name": "Q3 Checkout Friction",
    "project_id": "3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1",
    "state": "DRAFT",
    "total_questions": 14,
    "…": "remaining overview and interviewer settings omitted — see the field table above",
    "modified": "2026-08-12T09:20:07Z",
    "source_study_id": "e2b6a03d-8c14-4f77-90ab-1d5c7e93f204",
    "source_study_name": "Checkout Friction — 2025 baseline",
    "sections_copied": 3,
    "questions_copied": 14
  }
}

Destructive and not undoable through the API: the target study's existing sections, questions, screener, and monadic configuration are deleted before the copy is written. Duplicate the study first if you need a fallback.

Move a study to another project #
POST /v2/studies/{study_id}/move/ proposed
Scope studies:write Credentials: user-delegated, partner

Reparents the study, and the stimuli it uses, under a different project. Both the study and the destination project must be reachable by your credential.

Path parameters
NameTypeDescription
study_iduuidID of the study to move.
Request body

The destination.

FieldTypeDescription
project_id required uuid Project to move the study into; it must have the same type and format as the study's current project.
{
  "project_id": "6d0a4b31-c7e5-4a29-8f13-b2740e6c9a58"
}
Response

The moved study, in the same shape as `GET /v2/studies/{study_id}/` without the content graph.

FieldTypeDescription
data.iduuidStudy ID.
data.namestringDisplay name of the study.
data.project_iduuidProject the study belongs to.
data.stateenumLifecycle state: `DRAFT`, `LIVE`, `PAUSED`, or `CLOSED`.
data.activebooleanWhether the study is accepting new participants right now.
data.interview_methodenumHow participants take the interview.
data.moderation_typeenumWhether the interview is AI-moderated or human-moderated.
data.contextstringBackground the AI interviewer is given about the study.
data.goalsarray[string]Research objectives for the study, or `null` when none are set.
data.language_codestringISO 639-1 code of the interview language.
data.base_language_codestringThe participant-facing default language and the language recruitment copy is written in.
data.welcome_messagestringMessage shown to participants before the interview starts.
data.end_messagestringMessage shown after participants finish.
data.completion_urlurlWhere participants are redirected after finishing, for self-managed external panels; empty when Outset handles the redirect.
data.max_interviewsintegerCap on completed interviews for this study.
data.interviewer_personaenumBuilt-in interviewer style: `HIGH_TWO_FOLLOW_UP` ("Standard"), `LOW_TWO_FOLLOW_UP` ("Neutral"), or `NONE`.
data.custom_interviewer_persona_iduuidTrained custom persona assigned to the study, or `null` when a built-in persona is used.
data.interviewer_voice_engineenum`SIMPLE` uses the platform default voice for the language; `ADVANCED` uses a voice picked from the voice catalog.
data.interviewer_voice_idstringCatalog voice in use, or empty when the engine is `SIMPLE`.
data.flag_piibooleanWhether participant messages are scanned for personal identifiers and flagged for review.
data.pii_flag_settingsarray[object]Effective per-category PII settings, each with `category`, `enabled`, `review_mode`, and `locked` (true when the organization enforces the value).
data.visual_intelligence_enabledbooleanWhether AI analysis of participant recordings has been approved for this study.
data.auto_close_enabledbooleanWhether the organization's auto-close policies may close this study.
data.total_questionsintegerNumber of guide questions, excluding matrix sub-rows.
data.createddatetimeWhen the study was created (ISO 8601, UTC).
data.modifieddatetimeWhen the study was last edited (ISO 8601, UTC).
data.urlurlDeep link to the study under its new project.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "name": "Q3 Checkout Friction",
    "project_id": "6d0a4b31-c7e5-4a29-8f13-b2740e6c9a58",
    "state": "LIVE",
    "active": true,
    "interview_method": "VOICE",
    "total_questions": 12,
    "…": "remaining overview and interviewer settings omitted — see the field table above",
    "modified": "2026-08-12T09:31:55Z",
    "url": "https://app.outset.ai/project/6d0a4b31-c7e5-4a29-8f13-b2740e6c9a58/survey/9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42"
  }
}
Errors
StatusCodeWhen
400incompatible_project_for_moveThe destination project's type or format differs from the study's current project.

Moving regenerates the study's auto-generated report templates. Studies backing a diary session cannot be moved.

Publish a study #
POST /v2/studies/{study_id}/publish/ proposed
Scope studies:launch Credentials: user-delegated, partner

Takes the study live so it can accept participants, running the same pre-publish validation as the web app. Publishing does not recruit anyone — pair it with the recruitment endpoints to bring participants in.

Path parameters
NameTypeDescription
study_iduuidID of the study to publish.
Response

The study's new launch state.

FieldTypeDescription
data.iduuidStudy ID.
data.stateenumLifecycle state after the call — always `LIVE` on success.
data.already_activebooleanTrue when the study was already live and this call changed nothing.
data.start_urlurlParticipant-facing link to hand out; present only for self-recruited studies, since panel-recruited participants arrive through the provider.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "state": "LIVE",
    "already_active": false,
    "start_url": "https://app.outset.ai/s/9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42"
  }
}
Errors
StatusCodeWhen
400validation_errorThe study fails a pre-publish check — no questions, invalid screener rules, or a role that requires admin approval. Call `POST /v2/studies/{study_id}/validate/` first to get the issues as a structured list.
402insufficient_credit_balanceThe workspace budget or recruitment wallet cannot cover the study's committed spend.
409study_closedThe study has been closed; a closed study can never be published again.

Idempotent — publishing a live study returns already_active: true with no further effect.

Unpublish a study #
POST /v2/studies/{study_id}/unpublish/ proposed
Scope studies:launch Credentials: user-delegated, partner

Pauses the study so it stops accepting new participants. The study can be published again later.

Path parameters
NameTypeDescription
study_iduuidID of the study to unpublish.
Response

The study's new launch state.

FieldTypeDescription
data.iduuidStudy ID.
data.stateenumLifecycle state after the call — always `PAUSED` on success.
data.already_inactivebooleanTrue when the study was already paused and this call changed nothing.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "state": "PAUSED",
    "already_inactive": false
  }
}
Errors
StatusCodeWhen
409study_closedThe study is already terminally closed, so there is nothing to pause.

Unpublishing does not cancel a recruitment already launched with a third-party panel — those slots keep their state. Interviews already in progress also keep running and can still complete.

Close a study #
POST /v2/studies/{study_id}/close/ proposed
Scope studies:launch Credentials: user-delegated, partner

Permanently closes the study: it stops accepting participants, any active third-party recruitment is cancelled, and its incentives are deactivated.

Path parameters
NameTypeDescription
study_iduuidID of the study to close.
Response

The study's terminal state.

FieldTypeDescription
data.iduuidStudy ID.
data.stateenumLifecycle state after the call — always `CLOSED` on success.
data.closed_atdatetimeWhen the study was closed (ISO 8601, UTC).
data.closed_methodenum`MANUAL` for an API or UI close; `AUTO_INACTIVITY_30D` or `AUTO_HARD_LIMIT_90D` when the organization's policy closed it earlier.
data.already_closedbooleanTrue when the study was already closed and this call changed nothing.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "state": "CLOSED",
    "closed_at": "2026-08-12T09:41:18Z",
    "closed_method": "MANUAL",
    "already_closed": false
  }
}

Irreversible. A closed study can never be published or reopened — use unpublish if you only want to pause. Closing stops arrivals, not interviews: sessions already open keep running and can still complete for up to 24 hours, so unfilled recruitment spend is settled and the completion email sent about a day later, not at the moment you close.

Check whether a study can be published #
POST /v2/studies/{study_id}/validate/ proposed
Scope studies:read Credentials: user-delegated, partner

Runs the full pre-publish validation chain without changing anything, and returns the issues as a structured list. Use it before publish so you can fix problems by code instead of parsing an error string.

Path parameters
NameTypeDescription
study_iduuidID of the study to check.
Response

The validation result.

FieldTypeDescription
data.iduuidStudy ID.
data.ready_to_publishbooleanTrue only when `issues` is empty.
data.activebooleanWhether the study is already live.
data.issuesarray[object]One entry per blocking problem, each with a stable `code`, a human-readable `message`, and context fields such as `question_id` or `question_study_order` where they apply.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "ready_to_publish": false,
    "active": false,
    "issues": [
      {
        "code": "approval_required",
        "message": "Publishing this study requires approval from an organization admin. Reasons: workspace_budget_exceeded.",
        "reasons": [
          "workspace_budget_exceeded"
        ]
      }
    ]
  }
}

A snapshot, not a reservation: workspace budget and interview counts can move between this call and publish, which stays the authoritative gate. POST rather than GET because the check fans out to external services, but it never writes.

Estimate how long the study takes #
GET /v2/studies/{study_id}/time-estimate/ proposed
Scope studies:read Credentials: user-delegated, partner

Returns the duration breakdown the study editor shows, as ready-to-quote display strings. Use these verbatim wherever your copy mentions how long the interview takes, so participants see the same number the researcher does.

Path parameters
NameTypeDescription
study_iduuidID of the study to estimate.
Response

Display labels for the whole session and each part of it. Labels use a mixed-unit format: `45 sec`, `1 min 20 sec`, `21 mins`, `1 hour 23 min`.

FieldTypeDescription
data.total_labelstringThe full participant session — welcome, screener, and all sections.
data.welcome_labelstringThe welcome and closing steps, or `null` when the study has no questions yet.
data.screener_labelstringThe screener portion only, or `null` when the study has no screener.
data.sectionsarray[object]One entry per section in display order, each with `name` and `duration_label`.
{
  "data": {
    "total_label": "21 mins",
    "welcome_label": "45 sec",
    "screener_label": "1 min 20 sec",
    "sections": [
      {
        "name": "Warm-up",
        "duration_label": "4 mins"
      },
      {
        "name": "Checkout walkthrough",
        "duration_label": "12 mins"
      },
      {
        "name": "Wrap-up",
        "duration_label": "3 mins"
      }
    ]
  }
}

The estimate moves as the guide changes — re-read it after editing questions rather than caching a number.

Run a quality check #
POST /v2/studies/{study_id}/quality-checks/ proposed
Scope studies:write Credentials: user-delegated, partner

Grades the study against Outset's research-quality rubric and returns 202 with a run to poll. A GUIDE check grades the questions (yes/no stems, double-barreled or leading phrasing, missing escape hatches, matrix misuse, and more); a CONTEXT check grades the free-text background given to the AI interviewer.

Path parameters
NameTypeDescription
study_iduuidID of the study to grade.
Request body

Which check to run.

FieldTypeDescription
type required enum `GUIDE` to grade the study's questions, or `CONTEXT` to grade its background text.
{
  "type": "GUIDE"
}
Response

The newly started run, with `status` set to `QUEUED`.

FieldTypeDescription
data.iduuidRun ID; poll it at `GET /v2/studies/{study_id}/quality-checks/{run_id}/`.
data.typeenum`GUIDE` or `CONTEXT`, echoing the request.
data.statusenum`QUEUED` on creation; it moves to `RUNNING`, then `COMPLETED` or `FAILED`.
{
  "data": {
    "id": "f81c26b4-30ad-4de9-8b57-c1e94a205d76",
    "type": "GUIDE",
    "status": "QUEUED"
  }
}

Each call spends model credits, so it is not safe to retry blindly. The check never edits the study — apply the suggestions yourself through the study-content endpoints. Takes studies:write rather than studies:read because each run is persisted and spends model credits; checks that write nothing (validate, the recruitment estimates) stay on the read scope.

Poll a quality check #
GET /v2/studies/{study_id}/quality-checks/{run_id}/ proposed
Scope studies:read Credentials: user-delegated, partner

Returns the run's current state. The shape is the same on every call: while grading, status is QUEUED or RUNNING and findings is empty; on completion it flips to COMPLETED with findings, or FAILED. Past runs stay readable, so you can compare results across edits.

Path parameters
NameTypeDescription
study_iduuidID of the study the run belongs to.
run_iduuidID returned when the check was started.
Response

The run and its findings.

FieldTypeDescription
data.iduuidRun ID.
data.typeenum`GUIDE` or `CONTEXT`; it determines which finding fields are populated.
data.statusenum`QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`.
data.summarystringOne-paragraph verdict on the study, empty until the run completes.
data.questions_checkedintegerHow many questions were graded; `GUIDE` runs only.
data.graded_internal_contextbooleanWhether the study's internal context — the private research context visible to the AI interviewer but never shown to participants — was graded as well; `CONTEXT` runs only.
data.findingsarray[object]Live findings, newest run state; dismissed findings are omitted.
data.findings[].iduuidFinding ID.
data.findings[].severityenum`high`, `medium`, or `low`.
data.findings[].categorystringRubric category the finding falls under — for context runs one of `interview_questions`, `conflicting_instructions`, `leading`, `too_long`, `other`.
data.findings[].titlestringShort label for the problem.
data.findings[].issuestringExplanation of what is wrong and why it hurts the interview.
data.findings[].suggestionstringProposed rewrite, or an empty string when no rewrite fits.
data.findings[].source_textstringThe exact text the finding is about.
data.findings[].target_typeenum`GUIDE_QUESTION` or `SCREENER_QUESTION`, saying which of the two ID fields below carries the reference; `GUIDE` runs only.
data.findings[].question_iduuidGuide question the finding is about, or `null`; `GUIDE` runs only.
data.findings[].screener_question_iduuidScreener question the finding is about, or `null`; `GUIDE` runs only.
data.findings[].study_orderintegerPosition of that question in the study, so you can point a researcher at it; `GUIDE` runs only.
data.findings[].source_fieldenumWhich part of the question the finding is about — `stem`, `probing_instructions`, `options`, `display_logic`, or `skip_logic`; `GUIDE` runs only.
data.findings[].target_fieldenumWhich context field the finding came from — `study_context` or `study_internal_context`; `CONTEXT` runs only.
{
  "data": {
    "id": "f81c26b4-30ad-4de9-8b57-c1e94a205d76",
    "type": "GUIDE",
    "status": "COMPLETED",
    "summary": "The guide is solid overall; two questions are double-barreled.",
    "questions_checked": 12,
    "findings": [
      {
        "id": "7a2d5c81-9e04-4b3f-a6c8-30f19b7e2d54",
        "target_type": "GUIDE_QUESTION",
        "question_id": "a7c30f18-4d92-4b6a-9f57-0e1b8c2d4a63",
        "screener_question_id": null,
        "study_order": 4,
        "severity": "medium",
        "category": "double_barreled",
        "title": "Two questions in one",
        "issue": "Asks about both delivery speed and packaging, so answers can't be attributed to either.",
        "suggestion": "How did you feel about the delivery speed?",
        "source_field": "stem",
        "source_text": "How did you feel about the delivery speed and the packaging?"
      }
    ]
  }
}

A failed run does not expose the underlying error text. Re-run the check; if it fails repeatedly, contact support with the run ID.

Read participant-flow stats #
GET /v2/studies/{study_id}/stats/ planned
Scope studies:read Credentials: user-delegated, partner

Returns how participants moved through the study — completions, screen-outs, over-quota and fraud counts — plus per-question reach and answer counts and a cost breakdown by transaction type. Built for audit and invoicing rather than analysis; interview transcripts and reports live in the analysis chapters.

Path parameters
NameTypeDescription
study_iduuidID of the study to report on.
Response

Summary, per-question reach, and costs.

FieldTypeDescription
data.study_iduuidThe study these stats describe.
data.summary.total_interviewsintegerParticipants who answered at least one question or were screened out, excluding archived interviews.
data.summary.total_completedintegerInterviews that finished cleanly — not screened out, not fraudulent, not low quality, not archived.
data.summary.total_incompleteintegerInterviews that started but did not finish, including sessions that timed out and low-quality ones.
data.summary.total_screened_outintegerParticipants the screener disqualified.
data.summary.total_over_quotaintegerParticipants turned away because their quota cell was already full.
data.summary.total_fraudintegerInterviews flagged as fraudulent.
data.summary.total_active_sessionsintegerInterviews still in progress within the 24-hour session window.
data.summary.total_archivedintegerInterviews the researcher archived.
data.summary.first_completed_atdatetimeWhen the first interview completed, or `null` when none have.
data.summary.last_completed_atdatetimeWhen the most recent interview completed, or `null` when none have.
data.summary.screen_out_limit_usdstringSpend cap on screen-out bonuses for the active recruitment, or `null` when there is none.
data.questions[].question_iduuidGuide question the row counts.
data.questions[].textstringThe question as participants saw it.
data.questions[].question_typeenumKind of question, matching the type in the study definition.
data.questions[].positionintegerPosition of the question in study order, from 0.
data.questions[].reached_countintegerClean interviews in which the question was presented.
data.questions[].answered_countintegerClean interviews in which the participant answered it, so reached minus answered is the drop-off at this question.
data.screener_questions[].question_iduuidScreener question the row counts.
data.screener_questions[].textstringThe screener question as participants saw it.
data.screener_questions[].question_typeenumKind of screener question, matching the type in the screener definition.
data.screener_questions[].positionintegerPosition of the question in screener order, from 0.
data.screener_questions[].reached_countintegerClean interviews in which the screener question was presented.
data.screener_questions[].answered_countintegerClean interviews in which the participant selected an option or typed an answer; the rows are absent entirely when the study has no screener.
data.costs.recruitment_created_usdstringSpend committed when recruitment was created.
data.costs.recruitment_participants_increased_usdstringSpend added by raising the participant target.
data.costs.recruitment_incentive_increased_usdstringSpend added by raising the per-participant reward.
data.costs.screened_out_bonus_usdstringBonuses paid to participants who were screened out.
data.costs.incentive_payout_usdstringIncentives paid to completed participants.
data.costs.concierge_recruitment_usdstringCharges for concierge (Outset-managed) recruitment.
data.costs.refund_usdstringAmounts refunded for unfilled recruitment.
{
  "data": {
    "study_id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "summary": {
      "total_interviews": 268,
      "total_completed": 204,
      "total_incomplete": 41,
      "total_screened_out": 19,
      "total_over_quota": 4,
      "total_fraud": 0,
      "total_active_sessions": 3,
      "total_archived": 2,
      "first_completed_at": "2026-08-02T11:04:19Z",
      "last_completed_at": "2026-08-11T18:52:07Z",
      "screen_out_limit_usd": "150.00"
    },
    "questions": [
      {
        "question_id": "a7c30f18-4d92-4b6a-9f57-0e1b8c2d4a63",
        "text": "Walk me through the last time you checked out on our site.",
        "question_type": "TEXT",
        "position": 0,
        "reached_count": 204,
        "answered_count": 201
      },
      {
        "…": "one row per guide question, in study order"
      }
    ],
    "screener_questions": [
      {
        "…": "same row shape, in screener order"
      }
    ],
    "costs": {
      "recruitment_created_usd": "2400.00",
      "screened_out_bonus_usd": "38.00",
      "incentive_payout_usd": "1020.00",
      "refund_usd": "0.00",
      "…": "remaining cost keys omitted — see the field table above"
    }
  }
}

Results are cached for an hour, so a stat can lag a just-completed interview. Costs are settled amounts only — pending transactions are excluded — and each key is a positive USD amount regardless of the transaction's sign.

Approve Visual Intelligence for a study #
PATCH /v2/studies/{study_id}/visual-intelligence/ planned
Scope studies:write Credentials: user-delegated, partner

Turns AI analysis of the study's participant recordings on or off. This is a compliance gate, not an edit surface: it accepts exactly one field and no other part of the study can be changed through it.

Path parameters
NameTypeDescription
study_iduuidID of the study to approve.
Request body

The single whitelisted field.

FieldTypeDescription
visual_intelligence_enabled required boolean Whether participant recordings for this study may be processed by Outset's vision models.
{
  "visual_intelligence_enabled": true
}
Response

The study's approval state after the change.

FieldTypeDescription
data.iduuidStudy ID.
data.visual_intelligence_enabledbooleanThe value now in effect.
{
  "data": {
    "id": "9c4f1e2a-6d3b-4f18-b2c7-5a0e8d1f3b42",
    "visual_intelligence_enabled": true
  }
}

Enabling this force-enables model processing of participant recordings, so every change is recorded in the study's audit history with the credential that made it. For partner credentials this is deliberately a cross-organization write: a partner administrator can approve on a study that lives in a client organization.

List interviewer voices #
GET /v2/interviewer-voices/ proposed
Scope studies:read Credentials: user-delegated, partner

Lists the voices available for the ADVANCED interviewer-voice engine, featured voices first. Pick an id here and set it as interviewer_voice_id on a study. The SIMPLE engine uses one default voice per language and ignores this catalog.

Query parameters
NameTypeDescription
language string · default en ISO 639-1 code to filter voices by; pass an empty value to return every voice regardless of language.
Response

The matching voices. Returned whole — the catalog is bounded, not paginated.

FieldTypeDescription
data[].iduuidVoice ID; pass it back as `interviewer_voice_id`.
data[].namestringHuman-facing name of the voice.
data[].descriptionstringShort blurb about how the voice sounds; may be empty.
data[].languagestringISO 639-1 code of the language the voice was trained for.
data[].genderstring`masculine`, `feminine`, or empty where unknown.
data[].accentstringRegional accent label; may be empty.
data[].tagsarray[string]Descriptor labels such as mood or age range.
data[].is_featuredbooleanWhether Outset recommends this voice.
{
  "data": [
    {
      "id": "b1d9f0c4-77a2-4e6b-9a31-2c5d8e4f1a09",
      "name": "Ava",
      "description": "Warm, unhurried, and easy to interrupt.",
      "language": "en",
      "gender": "feminine",
      "accent": "American",
      "tags": [
        "calm",
        "adult"
      ],
      "is_featured": true
    },
    {
      "…": "remaining voices omitted — featured voices come first"
    }
  ]
}

Voice-to-voice (INTERACTIVE) studies always run on the SIMPLE engine, so a voice picked here has no effect on them.

List custom interviewer personas #
GET /v2/interviewer-personas/ proposed
Scope studies:read Credentials: user-delegated, partner

Lists the trained custom interviewer personas your organization can assign to a study — your own and your organization's, excluding archived ones and any still training. Assign one with custom_interviewer_persona_id on the study.

Response

The assignable personas, newest first. Returned whole — the catalog is bounded, not paginated.

FieldTypeDescription
data[].iduuidPersona ID; pass it back as `custom_interviewer_persona_id`.
data[].display_namestringResearcher-facing name of the persona.
{
  "data": [
    {
      "id": "c58e2a71-0b94-4d63-97af-4e1c6b3d8f20",
      "display_name": "Clinical research lead"
    },
    {
      "…": "remaining personas omitted — newest first"
    }
  ]
}

The three built-in styles (HIGH_TWO_FOLLOW_UP, LOW_TWO_FOLLOW_UP, NONE) are not listed here — they are values of the study's interviewer_persona field, and a custom persona and a built-in one are mutually exclusive.

Chapter 07

Study Content: Sections, Questions & Logic #

A study's interview guide is an ordered list of sections, each holding an ordered list of questions. Sections come in three types — STANDARD, CONCEPT_TESTING (variants participants compare, with per-concept fields and stimuli), and PARTICIPANT_UPLOAD (participants submit a file the interview analyzes inline) — and a section can be converted between them in place without losing its questions. On top of that ordering sit three independent randomization mechanisms (section blocks, study-level section randomization, within-section question randomization) and two kinds of per-question logic (display conditions, which decide whether a question is asked, and skip conditions, which route the interview elsewhere once it is answered). Everything here is workspace-scoped through its study and requires studies:write to mutate; a published study's guide is frozen — every write in this chapter is refused with 409 published_study once the study is published, so edit before launch.

Question type drives which configuration fields are honored. The type-specific fields are documented on the create endpoint; this is the map:

question_type Required with it Notable optional fields
TEXT deep_probing, probing_instructions
MULTIPLE_CHOICE options allow_custom_other_text, preserve_options_order
MULTIPLE_SELECT options min_selections, max_selections, allow_custom_other_text
RATING rating_scale rating_labels (options are generated from the scale)
STACK_RANK options preserve_options_order
NUMBER_INPUT number_input_label
TYPED_RESPONSE typed_response_validation_enabled, typed_response_is_url, typed_response_max_retries
MATRIX matrix_preset, matrix_rows matrix_label_display, rating_labels (required for the CUSTOM preset)
TASK screenshare_url or allow_empty_screenshare_url task_completion_definition, figma_analytics_enabled
INFO_ONLY ask_verbatim
IMAGE_CODESIGN — (attach the base image as a stimulus) codesign_max_tries

TASK is only available on usability studies (screenshare and mobile UX), MATRIX can only be created (never converted into), and IMAGE_CODESIGN is entitlement-gated per organization — each rejects with 422 rather than silently downgrading.

List sections #
GET /v2/studies/{study_id}/sections/ proposed
Scope studies:read Credentials: user-delegated, partner

Lists the study's sections in guide order, with their type, randomization membership, and — for concept-testing and participant-upload sections — their type-specific configuration. Use this to discover the section IDs the rest of this chapter addresses.

Path parameters
NameTypeDescription
study_iduuidThe study whose guide you are reading.
Query parameters
NameTypeDescription
page_size integer · default 50 Sections per page, up to 200.
cursor string Opaque cursor from a previous page's `next_cursor`.
Response

Cursor-paginated list of sections in guide order (oldest position first, unlike the newest-first default).

FieldTypeDescription
data[].iduuidSection identifier.
data[].namestringSection name, shown to participants and in the study editor.
data[].typeenumOne of `STANDARD`, `CONCEPT_TESTING`, `PARTICIPANT_UPLOAD`.
data[].positionintegerZero-indexed position of the section in the guide.
data[].question_countintegerNumber of questions in the section, excluding matrix rows.
data[].section_block_iduuid|nullSection block this section belongs to, or null if it is standalone.
data[].randomize_conceptsboolean|nullConcept-testing sections only: whether concept order is randomized per participant.
data[].randomize_concepts_countinteger|nullConcept-testing sections only: how many concepts each participant sees, or null for all of them.
data[].upload_configobject|nullParticipant-upload sections only: the section's upload configuration, in the shape returned by the upload-config endpoint.
{
  "data": [
    {
      "id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
      "name": "Warm-up",
      "type": "STANDARD",
      "position": 0,
      "question_count": 3,
      "section_block_id": null,
      "randomize_concepts": null,
      "randomize_concepts_count": null,
      "upload_config": null
    },
    {
      "id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
      "name": "Tagline variants",
      "type": "CONCEPT_TESTING",
      "position": 1,
      "question_count": 2,
      "section_block_id": "b2c7e4a8-3f56-4d90-a1c3-77e0d4f9b512",
      "randomize_concepts": true,
      "randomize_concepts_count": 2,
      "upload_config": null
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Create a section #
POST /v2/studies/{study_id}/sections/ proposed
Scope studies:write Credentials: user-delegated, partner

Creates a section of any of the three types. A CONCEPT_TESTING section can be seeded with concept names inline; a PARTICIPANT_UPLOAD section is created with a default upload config (image, one file, requirements validation on) that you then complete through the upload-config endpoint.

Path parameters
NameTypeDescription
study_iduuidThe study to add the section to.
Request body

Section name plus the type and any type-specific seed values.

FieldTypeDescription
name required string Section name, shown to participants and in the study editor.
type enum `STANDARD` (default), `CONCEPT_TESTING`, or `PARTICIPANT_UPLOAD`.
position integer Zero-indexed position in the guide; omit to append at the end.
concept_names array[string] Concept-testing only: names of the concepts to seed the section with, in order.
randomize_concepts boolean Concept-testing only: whether each participant sees the concepts in a random order (defaults to true).
randomize_concepts_count integer Concept-testing only: how many concepts each participant sees; omit to show every concept.
media_type enum Participant-upload only: `IMAGE` (default), `VIDEO`, or `DOCUMENT`.
{
  "name": "Tagline variants",
  "type": "CONCEPT_TESTING",
  "concept_names": [
    "Variant A",
    "Variant B",
    "Variant C"
  ],
  "randomize_concepts": true,
  "randomize_concepts_count": 2
}
Response

The created section, in the shape returned by the list endpoint.

FieldTypeDescription
data.iduuidSection identifier.
data.namestringSection name, shown to participants and in the study editor.
data.typeenumOne of `STANDARD`, `CONCEPT_TESTING`, `PARTICIPANT_UPLOAD`.
data.positionintegerZero-indexed position of the section in the guide.
data.question_countintegerNumber of questions in the section, excluding matrix rows.
data.section_block_iduuid|nullSection block this section belongs to, or null if it is standalone.
data.randomize_conceptsboolean|nullConcept-testing sections only: whether concept order is randomized per participant.
data.randomize_concepts_countinteger|nullConcept-testing sections only: how many concepts each participant sees, or null for all of them.
data.upload_configobject|nullParticipant-upload sections only: the section's upload configuration, in the shape returned by the upload-config endpoint.
data.created_attimestampWhen the section was created, ISO 8601 UTC.
{
  "data": {
    "id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Tagline variants",
    "type": "CONCEPT_TESTING",
    "position": 1,
    "question_count": 0,
    "section_block_id": null,
    "randomize_concepts": true,
    "randomize_concepts_count": 2,
    "upload_config": null,
    "created_at": "2026-08-11T14:03:22Z"
  }
}
Errors
StatusCodeWhen
403feature_not_enabled`VIDEO` or `DOCUMENT` uploads are requested and the organization is not entitled to them — the request is rejected rather than downgraded to `IMAGE`.

A concept-testing section created with concept_names still needs each concept's content (field values or a stimulus) before the study will pass publish validation, and a participant-upload section needs upload instructions and at least one question.

Update a section #
PATCH /v2/studies/{study_id}/sections/{section_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Renames a section, changes concept randomization, or converts the section to another type in place, keeping its questions. Only the fields you send are changed.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
section_iduuidThe section to update.
Request body

Any subset of the section's mutable fields.

FieldTypeDescription
name string New section name.
type enum Convert the section to `STANDARD`, `CONCEPT_TESTING`, or `PARTICIPANT_UPLOAD`, keeping its questions.
randomize_concepts boolean Concept-testing only: whether each participant sees the concepts in a random order.
randomize_concepts_count integer|null Concept-testing only: how many concepts each participant sees; send `null` to go back to showing all of them.
Response

The updated section, plus the fields the request actually changed.

FieldTypeDescription
data.iduuidSection identifier.
data.namestringSection name, shown to participants and in the study editor.
data.typeenumOne of `STANDARD`, `CONCEPT_TESTING`, `PARTICIPANT_UPLOAD`.
data.positionintegerZero-indexed position of the section in the guide.
data.question_countintegerNumber of questions in the section, excluding matrix rows.
data.section_block_iduuid|nullSection block this section belongs to, or null if it is standalone.
data.randomize_conceptsboolean|nullConcept-testing sections only: whether concept order is randomized per participant.
data.randomize_concepts_countinteger|nullConcept-testing sections only: how many concepts each participant sees, or null for all of them.
data.upload_configobject|nullParticipant-upload sections only: the section's upload configuration, in the shape returned by the upload-config endpoint.
data.created_attimestampWhen the section was created, ISO 8601 UTC.
data.changesarray[object]One entry per field that changed, each naming the `field` with its `old` and `new` value; empty when the section already had the requested values.
{
  "data": {
    "id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Tagline variants",
    "type": "CONCEPT_TESTING",
    "position": 1,
    "question_count": 4,
    "section_block_id": null,
    "randomize_concepts": true,
    "randomize_concepts_count": 2,
    "upload_config": null,
    "created_at": "2026-08-11T14:03:22Z",
    "changes": [
      {
        "field": "randomize_concepts_count",
        "old": null,
        "new": 2
      }
    ]
  }
}
Errors
StatusCodeWhen
422invalid_for_section_typeA concept-randomization field is sent for a section that is not a concept-testing section.

Converting into CONCEPT_TESTING seeds a default concept table; converting into PARTICIPANT_UPLOAD seeds an image upload config. Convert first, then set randomization or upload settings in a second call — the conversion ignores them.

Delete a section #
DELETE /v2/studies/{study_id}/sections/{section_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Deletes a section and every question inside it. Logic on other questions that referenced the deleted questions is cascaded away.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
section_iduuidThe section to delete.
Response

`204 No Content` — empty body. The section and its questions are gone; subsequent reads of them 404.

Irreversible through the API, and it takes the section's questions with it. To change a section's type without losing questions, use PATCH with type instead of deleting and re-creating.

Duplicate a section #
POST /v2/studies/{study_id}/sections/{section_id}/duplicate/ proposed
Scope studies:write Credentials: user-delegated, partner

Copies a section and everything in it — questions with their options, stimuli, display and skip conditions and validation rules, plus concepts and concept fields or the upload config. The copy is placed immediately after the original.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
section_iduuidThe section to duplicate.
Response

The new section, in the shape returned by the list endpoint — its own `id`, the position right after the original, and the copied question count.

FieldTypeDescription
data.iduuidSection identifier.
data.namestringSection name, shown to participants and in the study editor.
data.typeenumOne of `STANDARD`, `CONCEPT_TESTING`, `PARTICIPANT_UPLOAD`.
data.positionintegerZero-indexed position of the section in the guide.
data.question_countintegerNumber of questions in the section, excluding matrix rows.
data.section_block_iduuid|nullSection block this section belongs to, or null if it is standalone.
data.randomize_conceptsboolean|nullConcept-testing sections only: whether concept order is randomized per participant.
data.randomize_concepts_countinteger|nullConcept-testing sections only: how many concepts each participant sees, or null for all of them.
data.upload_configobject|nullParticipant-upload sections only: the section's upload configuration, in the shape returned by the upload-config endpoint.
data.created_attimestampWhen the section was created, ISO 8601 UTC.
{
  "data": {
    "id": "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94",
    "name": "Tagline variants (copy)",
    "type": "CONCEPT_TESTING",
    "position": 2,
    "question_count": 4,
    "section_block_id": null,
    "randomize_concepts": true,
    "randomize_concepts_count": 2,
    "upload_config": null,
    "created_at": "2026-08-11T14:22:05Z"
  }
}

Condition rules pointing at questions inside the section are rewired to the copies; rules pointing at questions elsewhere in the guide keep pointing at the originals.

Reorder sections #
POST /v2/studies/{study_id}/sections/reorder/ proposed
Scope studies:write Credentials: user-delegated, partner

Sets the guide order of the study's sections. Send the complete ordered list, not a diff.

Path parameters
NameTypeDescription
study_iduuidThe study whose sections you are reordering.
Request body

The full desired order.

FieldTypeDescription
section_ids required array[uuid] Every section on the study, in the order you want; the first becomes position 0.
{
  "section_ids": [
    "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
    "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8"
  ]
}
Response

The section order as applied: position 0 first.

FieldTypeDescription
data.section_idsarray[uuid]Every section on the study, in its new guide order; the first is at position 0.
{
  "data": {
    "section_ids": [
      "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
      "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401"
    ]
  }
}
Errors
StatusCodeWhen
422incomplete_orderThe list omits a section that exists on the study, or names an unknown one — partial lists are rejected rather than partially applied.
Update participant-upload configuration #
PATCH /v2/studies/{study_id}/sections/{section_id}/upload-config/ proposed
Scope studies:write Credentials: user-delegated, partner

Configures what participants upload in a participant-upload section and how their files are validated. Only the fields you send are changed.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
section_iduuidThe participant-upload section to configure.
Request body

Any subset of the upload configuration.

FieldTypeDescription
instructions string Prompt telling participants what to upload, e.g. "Upload a photo of your receipt".
requirements string What an uploaded file must show to be accepted; also used as the analysis criteria when validation is enforced.
enforce_requirements_validation boolean Whether uploads are checked against `requirements` before being accepted.
requirements_bypass_max_attempts integer How many failed validation attempts a participant may make before being allowed through anyway (minimum 1).
max_files integer Maximum number of files one participant may upload in this section (minimum 1).
media_type enum `IMAGE`, `VIDEO`, or `DOCUMENT`.
{
  "instructions": "Upload a photo of your most recent grocery receipt.",
  "requirements": "The total and the purchase date must be legible.",
  "enforce_requirements_validation": true,
  "requirements_bypass_max_attempts": 2,
  "max_files": 1
}
Response

The section's complete upload configuration after the change, plus the fields the request actually changed.

FieldTypeDescription
data.section_iduuidThe participant-upload section this configuration belongs to.
data.instructionsstringPrompt telling participants what to upload.
data.requirementsstringWhat an uploaded file must show to be accepted.
data.enforce_requirements_validationbooleanWhether uploads are checked against `requirements` before being accepted.
data.requirements_bypass_max_attemptsintegerHow many failed validation attempts a participant may make before being allowed through anyway.
data.max_filesintegerMaximum number of files one participant may upload in this section.
data.media_typeenum`IMAGE`, `VIDEO`, or `DOCUMENT`.
data.changesarray[object]One entry per field that changed, each naming the `field` with its `old` and `new` value.
{
  "data": {
    "section_id": "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94",
    "instructions": "Upload a photo of your most recent grocery receipt.",
    "requirements": "The total and the purchase date must be legible.",
    "enforce_requirements_validation": true,
    "requirements_bypass_max_attempts": 2,
    "max_files": 1,
    "media_type": "IMAGE",
    "changes": [
      {
        "field": "max_files",
        "old": 3,
        "new": 1
      }
    ]
  }
}
Errors
StatusCodeWhen
404not_foundThe section exists but is not a participant-upload section — it has no upload config to address.
403feature_not_enabledSwitching to `VIDEO` or `DOCUMENT` without the organization being entitled to it. Keeping the current value is always allowed.
Create a section block #
POST /v2/studies/{study_id}/section-blocks/ proposed
Scope studies:write Credentials: user-delegated, partner

Groups sections into a named block with shared presentation rules — the mechanism for randomizing several independent sets of sections. Members need not be adjacent in the guide.

Path parameters
NameTypeDescription
study_iduuidThe study to create the block on.
Request body

Block name, membership, and presentation rules.

FieldTypeDescription
name required string Researcher-facing block name; must be unique within the study.
section_ids required array[uuid] Sections that make up the block; at least one.
mode enum `NONE` (default, authored order), `MONADIC` (a balanced random subset, then shuffled), or `SEQUENTIAL_MONADIC` (all members in a balanced random order).
subset_count integer Required when `mode` is `MONADIC`: how many members each participant sees, from 1 up to the member count.
randomize_block_position boolean Whether this block may trade its guide position with other blocks that also opt in (defaults to false).
{
  "name": "Concept rotation",
  "section_ids": [
    "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94"
  ],
  "mode": "SEQUENTIAL_MONADIC",
  "randomize_block_position": false
}
Response

The created block with its members.

FieldTypeDescription
data.iduuidBlock identifier.
data.namestringResearcher-facing block name, unique within the study.
data.modeenum`NONE` (authored order), `MONADIC` (a balanced random subset, then shuffled), or `SEQUENTIAL_MONADIC` (all members in a balanced random order).
data.subset_countinteger|nullHow many members each participant sees; set only in `MONADIC` mode.
data.randomize_block_positionbooleanWhether this block may trade its guide position with other blocks that also opt in.
data.section_idsarray[uuid]The block's member sections, in guide order.
{
  "data": {
    "id": "b2c7e4a8-3f56-4d90-a1c3-77e0d4f9b512",
    "name": "Concept rotation",
    "mode": "SEQUENTIAL_MONADIC",
    "subset_count": null,
    "randomize_block_position": false,
    "section_ids": [
      "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
      "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94"
    ]
  }
}
Errors
StatusCodeWhen
409randomization_conflictThe study already uses study-level section randomization. A study uses blocks or that, never both — clear it first.
Update a section block #
PATCH /v2/studies/{study_id}/section-blocks/{block_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Renames a block, changes how its members are presented, or replaces its membership. Omitted fields are left alone.

Path parameters
NameTypeDescription
study_iduuidThe study the block belongs to.
block_iduuidThe block to update.
Request body

Any subset of the block's fields.

FieldTypeDescription
name string New block name; must stay unique within the study.
mode enum `NONE`, `MONADIC`, or `SEQUENTIAL_MONADIC`.
subset_count integer Required when the resulting mode is `MONADIC`.
randomize_block_position boolean Whether this block may trade guide position with other opted-in blocks.
section_ids array[uuid] Replaces the membership outright: sections listed join, current members not listed become standalone.
Response

The updated block with its membership as it now stands.

FieldTypeDescription
data.iduuidBlock identifier.
data.namestringResearcher-facing block name, unique within the study.
data.modeenum`NONE` (authored order), `MONADIC` (a balanced random subset, then shuffled), or `SEQUENTIAL_MONADIC` (all members in a balanced random order).
data.subset_countinteger|nullHow many members each participant sees; set only in `MONADIC` mode.
data.randomize_block_positionbooleanWhether this block may trade its guide position with other blocks that also opt in.
data.section_idsarray[uuid]The block's member sections, in guide order.
{
  "data": {
    "id": "b2c7e4a8-3f56-4d90-a1c3-77e0d4f9b512",
    "name": "Concept rotation",
    "mode": "SEQUENTIAL_MONADIC",
    "subset_count": null,
    "randomize_block_position": false,
    "section_ids": [
      "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
      "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94"
    ]
  }
}

Sending a section_ids list that empties the block deletes the block; its sections survive as standalone sections.

Delete a section block #
DELETE /v2/studies/{study_id}/section-blocks/{block_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Removes the block. Its sections are kept and become standalone sections again, in their existing guide positions.

Path parameters
NameTypeDescription
study_iduuidThe study the block belongs to.
block_iduuidThe block to delete.
Response

`204 No Content` — empty body. The block is gone; its member sections survive as standalone sections.

Set study-level section randomization #
PUT /v2/studies/{study_id}/section-randomization/ proposed
Scope studies:write Credentials: user-delegated, partner

Randomizes the order — or the selection — of sections across participants (monadic testing) for one set of sections. Idempotent: the body is the complete desired state, and sections attached today but absent from the body are detached.

Path parameters
NameTypeDescription
study_iduuidThe study to configure.
Request body

The randomization mode and the complete set of participating sections.

FieldTypeDescription
mode required enum `SEQUENTIAL` (every participant sees every attached section, in a random order) or `SUBSET` (each participant sees `subset_count` of them).
subset_count integer Required when `mode` is `SUBSET`: how many sections each participant sees, from 1 up to the number attached.
section_ids required array[uuid] The complete set of sections that participate; any section attached but not listed is detached.
{
  "mode": "SEQUENTIAL",
  "section_ids": [
    "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94"
  ]
}
Response

The stored randomization configuration and the sections attached to it.

FieldTypeDescription
data.iduuidRandomization configuration identifier.
data.modeenum`SEQUENTIAL` (everything attached, in a random order) or `SUBSET` (each participant sees `subset_count` of them).
data.subset_countinteger|nullHow many of the attached items each participant sees; set only in `SUBSET` mode.
data.section_idsarray[uuid]The sections now attached to the configuration — the set you sent.
{
  "data": {
    "id": "f41ba9d7-6c30-4e58-90b2-3d7c15e08a29",
    "mode": "SEQUENTIAL",
    "subset_count": null,
    "section_ids": [
      "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
      "c05a1d77-8b41-4a63-9e2f-b3d18a6c7e94"
    ]
  }
}
Errors
StatusCodeWhen
409randomization_conflictThe study already uses section blocks. Use one mechanism or the other.

This is the single-set mechanism. For more than one independently randomized set of sections, use section blocks instead.

Clear study-level section randomization #
DELETE /v2/studies/{study_id}/section-randomization/ proposed
Scope studies:write Credentials: user-delegated, partner

Turns off study-level section randomization. Every attached section is detached and returns to its authored guide position.

Path parameters
NameTypeDescription
study_iduuidThe study to clear.
Response

`204 No Content` — empty body. No configuration remains and every section is detached; clearing again is a no-op.

Set within-section question randomization #
PUT /v2/studies/{study_id}/sections/{section_id}/question-randomization/ proposed
Scope studies:write Credentials: user-delegated, partner

Randomizes the order — or the selection — of questions inside one section. There is one configuration per section, so every participating question shares the mode.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
section_iduuidThe section whose questions are randomized.
Request body

The randomization mode and the complete set of participating questions.

FieldTypeDescription
mode required enum `SEQUENTIAL` (every attached question, in a random order) or `SUBSET` (each participant sees `subset_count` of them).
subset_count integer Required when `mode` is `SUBSET`: how many questions each participant sees.
question_ids required array[uuid] The complete set of participating questions, all belonging to this section; questions attached but not listed revert to their fixed order.
Response

The stored configuration and the questions attached to it.

FieldTypeDescription
data.iduuidRandomization configuration identifier.
data.modeenum`SEQUENTIAL` (everything attached, in a random order) or `SUBSET` (each participant sees `subset_count` of them).
data.subset_countinteger|nullHow many of the attached items each participant sees; set only in `SUBSET` mode.
data.section_iduuidThe section this configuration belongs to.
data.question_idsarray[uuid]The questions now attached to the configuration — the set you sent.
{
  "data": {
    "id": "2b6c8f01-9d47-4a35-b0e8-51fa9c73d284",
    "mode": "SUBSET",
    "subset_count": 2,
    "section_id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
    "question_ids": [
      "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
      "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962"
    ]
  }
}
Clear within-section question randomization #
DELETE /v2/studies/{study_id}/sections/{section_id}/question-randomization/ proposed
Scope studies:write Credentials: user-delegated, partner

Turns off question randomization for one section; its questions return to their authored order.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
section_iduuidThe section to clear.
Response

`204 No Content` — empty body. The section's question randomization is gone and its questions return to their authored order.

List questions #
GET /v2/studies/{study_id}/questions/ proposed
Scope studies:read Credentials: user-delegated, partner

Lists the study's questions in guide order with their full per-type configuration, options, and attached logic. Matrix rows are returned inside their parent question rather than as top-level entries.

Path parameters
NameTypeDescription
study_iduuidThe study whose questions you are reading.
Query parameters
NameTypeDescription
section_id uuid Return only the questions in this section.
page_size integer · default 50 Questions per page, up to 200.
cursor string Opaque cursor from a previous page's `next_cursor`.
Response

Cursor-paginated list of questions in guide order. Fields that do not apply to a question's type are returned as null.

FieldTypeDescription
data[].iduuidQuestion identifier.
data[].section_iduuidSection the question belongs to.
data[].positionintegerZero-indexed position within the section.
data[].textstringThe question wording shown to participants.
data[].question_typeenumThe answer format — see the table in this chapter's introduction.
data[].optionsarray[object]Answer choices, each with `id`, `text`, and `anchor_type` (`OTHER`, `NONE`, or null); for rating and matrix questions these are the generated scale points.
data[].optionalbooleanWhether the participant may decline to answer and move on.
data[].deep_probingenumHow hard the AI interviewer probes after the first answer: `NONE`, `SINGLE`, `STANDARD`, `DEEP_PROBING`, or `ABYSS`.
data[].probing_instructionsstring|nullA scripted conditional probe the interviewer uses on top of the probing level.
data[].ask_verbatimbooleanWhether the interviewer must read the text exactly rather than rephrasing it.
data[].has_display_logicbooleanWhether display conditions gate this question.
data[].condition_modeenumHow display conditions are read: `SHOW_IF` (ask only when one matches) or `SKIP_IF` (skip when one matches).
data[].display_conditionsarray[object]Display conditions on this question, each with an `id` and its `rules`.
data[].skip_conditionsarray[object]Skip conditions on this question, each with an `id`, its `rules`, and either `target_question_id` or `end_immediately`.
data[].matrix_rowsarray[object]|nullMatrix questions only: the row statements, each with `id` and `text`, in display order.
data[].stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
data[].rating_scaleintegerNumber of points on a RATING question's scale; null for other question types.
data[].rating_labelsobjectOptional labels for the low and high ends of a RATING scale; null when unlabeled or not a RATING question.
{
  "data": [
    {
      "id": "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
      "section_id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
      "position": 0,
      "text": "How likely are you to recommend us to a friend?",
      "question_type": "RATING",
      "rating_scale": "ZERO_THROUGH_TEN",
      "rating_labels": [
        "Not at all likely",
        "Extremely likely"
      ],
      "options": [
        {
          "id": "d1b0c5a4-9e77-4a12-8c31-2f6b4d9e0a83",
          "text": "0",
          "anchor_type": null
        }
      ],
      "optional": false,
      "deep_probing": "STANDARD",
      "probing_instructions": null,
      "ask_verbatim": false,
      "has_display_logic": false,
      "condition_mode": "SHOW_IF",
      "display_conditions": [],
      "skip_conditions": [],
      "matrix_rows": null,
      "stimulus": null
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Create a question #
POST /v2/studies/{study_id}/questions/ proposed
Scope studies:write Credentials: user-delegated, partner

Adds a question to a section with its full per-type configuration. The type determines which of the fields below are honored; unknown fields are rejected, and fields that do not apply to the type are ignored.

Path parameters
NameTypeDescription
study_iduuidThe study to add the question to.
Request body

The question's wording, type, target section, and type-specific configuration.

FieldTypeDescription
section_id required uuid Section to add the question to.
text required string The question wording shown to participants.
question_type required enum One of `TEXT`, `MULTIPLE_CHOICE`, `MULTIPLE_SELECT`, `RATING`, `STACK_RANK`, `NUMBER_INPUT`, `TASK`, `INFO_ONLY`, `TYPED_RESPONSE`, `MATRIX`, `IMAGE_CODESIGN`.
position integer Zero-indexed position within the section; omit to append at the end.
options array Answer choices for `MULTIPLE_CHOICE`, `MULTIPLE_SELECT`, and `STACK_RANK`; each entry is a string or an object with `text` and `anchor_type` (`OTHER` or `NONE`).
rating_scale enum Required for `RATING`: `ZERO_THROUGH_TEN`, `ONE_THROUGH_FIVE`, `ONE_THROUGH_SEVEN`, or `ONE_THROUGH_TEN`; the scale points are generated for you.
rating_labels array[string] Endpoint labels for a rating scale, at least two, e.g. `["Not at all likely", "Extremely likely"]`.
deep_probing enum How hard the interviewer probes after the first answer — `NONE`, `SINGLE`, `STANDARD` (default), `DEEP_PROBING`, `ABYSS`.
probing_instructions string One scripted conditional probe: the shallow answer that triggers it and the single follow-up to ask.
ask_verbatim boolean Read the text exactly as written instead of letting the interviewer rephrase it (defaults to false).
optional boolean Let the participant decline to answer; mutually exclusive with skip logic on the same question.
has_display_logic boolean Whether display conditions gate this question; must be true for display conditions to take effect.
min_selections integer `MULTIPLE_SELECT` only: fewest options the participant must pick.
max_selections integer `MULTIPLE_SELECT` only: most options the participant may pick.
preserve_options_order boolean Keep the authored option order (default true); false randomizes it per participant to reduce order bias.
allow_custom_other_text boolean Let participants type their own text when they pick an `OTHER` anchor option.
screenshare_url string `TASK` only: the URL or deep link participants open to perform the task.
allow_empty_screenshare_url boolean `TASK` only: the task uses whatever is already on the participant's screen, so no URL is needed.
screenshare_enabled boolean Record the participant's screen for this question on a screenshare study.
task_completion_definition_enabled boolean `TASK` only: judge task completion against an explicit definition during post-interview analysis.
task_completion_definition string `TASK` only: what counts as completing the task, up to 255 characters.
figma_analytics_enabled boolean `TASK` only: capture a Figma prototype's frame geometry for the click heatmap; requires a connected Figma account and a Figma prototype URL.
matrix_preset enum Required for `MATRIX`: `AGREEMENT`, `DIFFICULTY`, `SATISFACTION`, `USEFULNESS`, `COMFORT`, `CLARITY`, `INTEREST`, `EASE`, `TRUST`, `PROFESSIONAL`, or `CUSTOM` (which requires your own scale points).
matrix_rows array[string] Required for `MATRIX`: the row statements being rated, at most 9.
matrix_label_display enum `MATRIX` only: `SHOW_ALL` (default) or `SHOW_FIRST_AND_LAST` to label only the endpoints.
stimulus_duration number Minimum seconds an attached stimulus must be shown before the participant can continue.
watermark_stimulus_enabled boolean Composite the interview's identifier onto the stimulus image so leaked screenshots are traceable.
codesign_max_tries integer `IMAGE_CODESIGN` only: how many image edits a participant may request, 1-5 (default 3).
number_input_label string `NUMBER_INPUT` only: label next to the numeric field; an empty string hides the label entirely.
{
  "section_id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
  "text": "How much do you agree with each statement about the checkout flow?",
  "question_type": "MATRIX",
  "matrix_preset": "AGREEMENT",
  "matrix_rows": [
    "It was easy to find what I needed",
    "I trusted the payment step"
  ],
  "matrix_label_display": "SHOW_FIRST_AND_LAST"
}
Response

The created question, in the shape returned by the list endpoint. Fields that do not apply to the question's type are returned as null.

FieldTypeDescription
data.iduuidQuestion identifier.
data.section_iduuidSection the question belongs to.
data.positionintegerZero-indexed position within the section.
data.textstringThe question wording shown to participants.
data.question_typeenumThe answer format — see the table in this chapter's introduction.
data.optionsarray[object]Answer choices, each with `id`, `text`, and `anchor_type` (`OTHER`, `NONE`, or null); for rating and matrix questions these are the generated scale points.
data.optionalbooleanWhether the participant may decline to answer and move on.
data.deep_probingenumHow hard the AI interviewer probes after the first answer: `NONE`, `SINGLE`, `STANDARD`, `DEEP_PROBING`, or `ABYSS`.
data.probing_instructionsstring|nullA scripted conditional probe the interviewer uses on top of the probing level.
data.ask_verbatimbooleanWhether the interviewer must read the text exactly rather than rephrasing it.
data.has_display_logicbooleanWhether display conditions gate this question.
data.condition_modeenumHow display conditions are read: `SHOW_IF` (ask only when one matches) or `SKIP_IF` (skip when one matches).
data.display_conditionsarray[object]Display conditions on this question, each with an `id` and its `rules`.
data.skip_conditionsarray[object]Skip conditions on this question, each with an `id`, its `rules`, and either `target_question_id` or `end_immediately`.
data.rating_scaleenum|nullRating questions only: the scale the generated options span.
data.rating_labelsarray[string]|nullEndpoint labels for a rating or custom-matrix scale.
data.matrix_presetenum|nullMatrix questions only: the preset the scale columns come from.
data.matrix_label_displayenum|nullMatrix questions only: `SHOW_ALL` or `SHOW_FIRST_AND_LAST`.
data.matrix_rowsarray[object]|nullMatrix questions only: the row statements, each with `id` and `text`, in display order.
data.stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
data.created_attimestampWhen the question was created, ISO 8601 UTC.
{
  "data": {
    "id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
    "section_id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
    "position": 3,
    "text": "How much do you agree with each statement about the checkout flow?",
    "question_type": "MATRIX",
    "matrix_preset": "AGREEMENT",
    "matrix_label_display": "SHOW_FIRST_AND_LAST",
    "matrix_rows": [
      {
        "id": "1c9f7d63-0b48-4e15-a2d9-53f7c1e08a44",
        "text": "It was easy to find what I needed"
      },
      {
        "id": "4b6a0e52-77c3-49d1-8f0a-9e21b5d3c706",
        "text": "I trusted the payment step"
      }
    ],
    "created_at": "2026-08-11T14:07:41Z"
  }
}
Errors
StatusCodeWhen
422incompatible_question_typeThe type cannot exist on this study or section — `TASK` outside a usability study, `IMAGE_CODESIGN` on a voice or mobile-UX study, on a non-standard section, or without the co-design entitlement.
422question_limit_exceededA matrix asks for more than 9 rows or more than 9 scale points; the participant renderer cannot draw it, so split the question instead.

Question text embedding a markdown image turns ask_verbatim on automatically — otherwise the interviewer could paraphrase the wording and the image would never be shown. MATRIX questions can only be created, never converted into.

Update a question #
PATCH /v2/studies/{study_id}/questions/{question_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Changes any subset of a question's wording, type, or configuration. It accepts the create body's fields plus the ones below, and a question's type can be converted in place — to any type except MATRIX.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question to update.
Request body

Any subset of the create fields, plus the edit-only fields below. Sending `options` replaces the whole option list; for a matrix it replaces the scale-point columns.

FieldTypeDescription
condition_mode enum How this question's display conditions are read: `SHOW_IF` (ask only when a condition matches) or `SKIP_IF` (skip when one matches).
typed_response_validation_enabled boolean `TYPED_RESPONSE` only: validate the participant's answer before accepting it.
typed_response_is_url boolean `TYPED_RESPONSE` only: require the answer to be an absolute URL.
typed_response_validation_root_op enum `TYPED_RESPONSE` only: `AND` requires every validation rule group to pass, `OR` any one of them.
typed_response_max_retries integer `TYPED_RESPONSE` only: how many failed validation attempts are allowed before the answer is accepted anyway, 1-5.
matrix_display_mode enum `MATRIX` only: currently `LIKERT` is the only supported layout.
clear_stimulus_duration boolean Remove the minimum stimulus display time entirely, rather than changing it.
clear_number_input_label boolean Restore the built-in `NUMBER_INPUT` label instead of a custom one.
Response

The updated question, plus the fields the request actually changed.

FieldTypeDescription
data.iduuidQuestion identifier.
data.section_iduuidSection the question belongs to.
data.positionintegerZero-indexed position within the section.
data.textstringThe question wording shown to participants.
data.question_typeenumThe answer format — see the table in this chapter's introduction.
data.optionsarray[object]Answer choices, each with `id`, `text`, and `anchor_type` (`OTHER`, `NONE`, or null); for rating and matrix questions these are the generated scale points.
data.optionalbooleanWhether the participant may decline to answer and move on.
data.deep_probingenumHow hard the AI interviewer probes after the first answer: `NONE`, `SINGLE`, `STANDARD`, `DEEP_PROBING`, or `ABYSS`.
data.probing_instructionsstring|nullA scripted conditional probe the interviewer uses on top of the probing level.
data.ask_verbatimbooleanWhether the interviewer must read the text exactly rather than rephrasing it.
data.has_display_logicbooleanWhether display conditions gate this question.
data.condition_modeenumHow display conditions are read: `SHOW_IF` (ask only when one matches) or `SKIP_IF` (skip when one matches).
data.display_conditionsarray[object]Display conditions on this question, each with an `id` and its `rules`.
data.skip_conditionsarray[object]Skip conditions on this question, each with an `id`, its `rules`, and either `target_question_id` or `end_immediately`.
data.rating_scaleenum|nullRating questions only: the scale the generated options span.
data.rating_labelsarray[string]|nullEndpoint labels for a rating or custom-matrix scale.
data.matrix_presetenum|nullMatrix questions only: the preset the scale columns come from.
data.matrix_label_displayenum|nullMatrix questions only: `SHOW_ALL` or `SHOW_FIRST_AND_LAST`.
data.matrix_rowsarray[object]|nullMatrix questions only: the row statements, each with `id` and `text`, in display order.
data.stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
data.created_attimestampWhen the question was created, ISO 8601 UTC.
data.changesarray[object]One entry per field that changed, each naming the `field` with its `old` and `new` value; empty when the question already had the requested values.
{
  "data": {
    "id": "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
    "section_id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
    "position": 0,
    "text": "How likely are you to recommend us to a colleague?",
    "question_type": "RATING",
    "rating_scale": "ZERO_THROUGH_TEN",
    "rating_labels": [
      "Not at all likely",
      "Extremely likely"
    ],
    "deep_probing": "DEEP_PROBING",
    "created_at": "2026-08-11T14:05:12Z",
    "changes": [
      {
        "field": "text",
        "old": "How likely are you to recommend us to a friend?",
        "new": "How likely are you to recommend us to a colleague?"
      },
      {
        "field": "deep_probing",
        "old": "STANDARD",
        "new": "DEEP_PROBING"
      }
    ]
  }
}

Converting a question's type keeps its wording and, where compatible, its options — but a type change can invalidate logic elsewhere that sources this question, since operators are validated against the source question's type.

Delete a question #
DELETE /v2/studies/{study_id}/questions/{question_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Deletes a question, its options, and its own skip and display conditions. Logic on other questions that referenced it is cascaded away, and skip conditions routing to it lose their target.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question to delete.
Response

`204 No Content` — empty body. The question is gone; subsequent reads of it 404.

Call the delete-preview endpoint first when other questions may depend on this one — the cascade is silent and irreversible.

Preview a question deletion #
GET /v2/studies/{study_id}/questions/{question_id}/delete-preview/ proposed
Scope studies:read Credentials: user-delegated, partner

Reports what deleting a question would break, without deleting anything: how much logic on other questions references it and would cascade away, and which skip conditions route to it.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question whose deletion you are previewing.
Response

The impact summary. Conditions owned by the question itself are not counted — they go with it and are not a surprise.

FieldTypeDescription
data.has_impactbooleanWhether any other question's logic depends on this one.
data.skip_condition_rules_affectedintegerSkip-logic rules on other questions that reference this question and would be removed.
data.skip_condition_targets_affectedintegerSkip conditions whose routing target is this question and would lose it.
data.display_condition_rules_affectedintegerDisplay-logic rules on other questions that reference this question and would be removed.
data.affected_question_idsarray[uuid]The other questions whose logic references this one.
{
  "data": {
    "has_impact": true,
    "skip_condition_rules_affected": 2,
    "skip_condition_targets_affected": 1,
    "display_condition_rules_affected": 0,
    "affected_question_ids": [
      "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
      "b7d40c19-8e26-4f53-a90c-2f16d5b8e347"
    ]
  }
}
Duplicate a question #
POST /v2/studies/{study_id}/questions/{question_id}/duplicate/ proposed
Scope studies:write Credentials: user-delegated, partner

Copies a question with its options, stimulus, display and skip conditions, validation rules, and matrix rows. The copy lands immediately after the original in the same section.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question to duplicate.
Response

The new question, in the shape returned by the list endpoint — its own `id`, the position right after the original, and the copied configuration.

FieldTypeDescription
data.iduuidQuestion identifier.
data.section_iduuidSection the question belongs to.
data.positionintegerZero-indexed position within the section.
data.textstringThe question wording shown to participants.
data.question_typeenumThe answer format — see the table in this chapter's introduction.
data.optionsarray[object]Answer choices, each with `id`, `text`, and `anchor_type` (`OTHER`, `NONE`, or null); for rating and matrix questions these are the generated scale points.
data.optionalbooleanWhether the participant may decline to answer and move on.
data.deep_probingenumHow hard the AI interviewer probes after the first answer: `NONE`, `SINGLE`, `STANDARD`, `DEEP_PROBING`, or `ABYSS`.
data.probing_instructionsstring|nullA scripted conditional probe the interviewer uses on top of the probing level.
data.ask_verbatimbooleanWhether the interviewer must read the text exactly rather than rephrasing it.
data.has_display_logicbooleanWhether display conditions gate this question.
data.condition_modeenumHow display conditions are read: `SHOW_IF` (ask only when one matches) or `SKIP_IF` (skip when one matches).
data.display_conditionsarray[object]Display conditions on this question, each with an `id` and its `rules`.
data.skip_conditionsarray[object]Skip conditions on this question, each with an `id`, its `rules`, and either `target_question_id` or `end_immediately`.
data.rating_scaleenum|nullRating questions only: the scale the generated options span.
data.rating_labelsarray[string]|nullEndpoint labels for a rating or custom-matrix scale.
data.matrix_presetenum|nullMatrix questions only: the preset the scale columns come from.
data.matrix_label_displayenum|nullMatrix questions only: `SHOW_ALL` or `SHOW_FIRST_AND_LAST`.
data.matrix_rowsarray[object]|nullMatrix questions only: the row statements, each with `id` and `text`, in display order.
data.stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
data.created_attimestampWhen the question was created, ISO 8601 UTC.
{
  "data": {
    "id": "b7d40c19-8e26-4f53-a90c-2f16d5b8e347",
    "section_id": "3f1c9a02-5b3d-4e21-9a77-6c2f0c1de401",
    "position": 1,
    "text": "How likely are you to recommend us to a friend?",
    "question_type": "RATING",
    "rating_scale": "ZERO_THROUGH_TEN",
    "rating_labels": [
      "Not at all likely",
      "Extremely likely"
    ],
    "skip_conditions": [
      {
        "id": "d90e5b72-31af-4c68-95d0-8b2e47c1a063",
        "target_question_id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
        "end_immediately": false,
        "rules": [
          "…"
        ]
      }
    ],
    "created_at": "2026-08-11T15:10:33Z"
  }
}

Rules on the copy that referenced the original are rewired to the copy itself; references to other questions are left alone. Re-creating a question by hand instead silently loses its conditions, stimulus, and validation rules.

Reorder or move questions #
POST /v2/studies/{study_id}/questions/reorder/ proposed
Scope studies:write Credentials: user-delegated, partner

Sets the order of questions, and optionally moves them all into another section. Send the complete final order of every question in the scope you are reordering, not a diff.

Path parameters
NameTypeDescription
study_iduuidThe study whose questions you are reordering.
Request body

The full desired order, plus an optional destination section.

FieldTypeDescription
question_ids required array[uuid] Every question in the scope, in the order you want; the first becomes position 0.
target_section_id uuid Move all listed questions into this section; omit to reorder them where they are.
{
  "question_ids": [
    "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
    "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962"
  ],
  "target_section_id": null
}
Response

The question order as applied, and the section the questions now sit in.

FieldTypeDescription
data.question_idsarray[uuid]Every question in the scope, in its new order; the first is at position 0.
data.target_section_iduuid|nullThe section the questions were moved into, or null when they stayed where they were.
{
  "data": {
    "question_ids": [
      "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
      "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61"
    ],
    "target_section_id": null
  }
}
Errors
StatusCodeWhen
422invalid_reorderThe list contains a matrix row — rows are ordered through their parent matrix question, not here.

Questions you leave out of the list keep their existing order values and will collide with the new ones, so list the whole scope. Moving a question into another section detaches it from its old section's question randomization.

Add a matrix row #
POST /v2/studies/{study_id}/questions/{question_id}/matrix-rows/ proposed
Scope studies:write Credentials: user-delegated, partner

Appends a row (a statement being rated) to a MATRIX question. Rows reuse the parent's scale, so only the statement text is supplied.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe parent matrix question.
Request body

The row statement.

FieldTypeDescription
text required string The statement participants rate on the matrix's scale.
{
  "text": "The delivery estimate was clear"
}
Response

The created row, appended after the matrix's existing rows.

FieldTypeDescription
data.iduuidRow identifier.
data.question_iduuidThe parent matrix question the row belongs to.
data.textstringThe statement participants rate on the matrix's scale.
data.positionintegerZero-indexed position of the row within the matrix.
{
  "data": {
    "id": "8e37b104-52cd-4a9f-b061-d7e2453f9c18",
    "question_id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
    "text": "The delivery estimate was clear",
    "position": 2
  }
}
Errors
StatusCodeWhen
422question_limit_exceededThe matrix already has 9 rows, the maximum the participant renderer can draw.
Update a matrix row #
PATCH /v2/studies/{study_id}/questions/{question_id}/matrix-rows/{row_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Changes a matrix row's statement text. Everything else about a row — scale, order, parent — is inherited from the matrix question.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe parent matrix question.
row_iduuidThe row to update.
Request body

The new statement text.

FieldTypeDescription
text required string New statement text for the row.
Response

The updated row.

FieldTypeDescription
data.iduuidRow identifier.
data.question_iduuidThe parent matrix question the row belongs to.
data.textstringThe statement participants rate on the matrix's scale.
data.positionintegerZero-indexed position of the row within the matrix.
{
  "data": {
    "id": "8e37b104-52cd-4a9f-b061-d7e2453f9c18",
    "question_id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
    "text": "The delivery estimate was clear",
    "position": 2
  }
}
Delete a matrix row #
DELETE /v2/studies/{study_id}/questions/{question_id}/matrix-rows/{row_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Removes a row from a matrix question. The remaining rows close the gap.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe parent matrix question.
row_iduuidThe row to delete.
Response

`204 No Content` — empty body. The row is gone and the remaining rows close the gap.

Add a skip condition #
POST /v2/studies/{study_id}/questions/{question_id}/skip-conditions/ proposed
Scope studies:write Credentials: user-delegated, partner

Adds routing to a question: once it is answered and every rule in the condition matches, the interview jumps to another question or ends. Rules inside a condition are ANDed; separate conditions on the same question are ORed, so post one condition per alternative.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question this routing lives on.
Request body

The routing destination and the rules that trigger it. Each rule object is `{source_type, source_question_id | source_screener_question_id | url_variable, operator, value | values}`.

FieldTypeDescription
target_question_id uuid Question to jump to when the rules match; must be later in the guide and mutually exclusive with `end_immediately`.
end_immediately boolean End the interview when the rules match, instead of jumping.
rules required array[object] At least one rule; all must match for the condition to fire.
rules[].source_type enum What the rule reads: `question` (default, a guide question's answer), `screener` (a screener answer), `url_variable` (a value supplied at recruitment time), or `prior_answers` (an AI read over the transcript so far).
rules[].source_question_id uuid The guide question whose answer is read; required for `source_type: question`, and usually the question the condition lives on.
rules[].source_screener_question_id uuid The screener question whose answer is read; required for `source_type: screener`.
rules[].url_variable string Name of the recruitment URL variable to evaluate; required for `source_type: url_variable`.
rules[].operator required enum The comparison, constrained by the source's type: `expresses`/`not_expresses` for open text and prior answers; `equals`/`not_equals` for single-choice; `contains`/`not_contains`/`includes_all`/`not_includes_all`/`includes_exactly`/`not_includes_exactly` for multi-select; `equals`/`not_equals`/`greater_than`/`less_than` for rating and number; `ranked_first`/`ranked_last` for stack rank; `task_completed`/`task_not_completed` for tasks.
rules[].value string What to compare against — an option's exact text, a number as a string, or the concept the AI should detect for `expresses`.
rules[].values array[string] Several option texts for an "is one of" rule; supply exactly one of `value` or `values`, and neither for the task-verdict operators.
{
  "target_question_id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
  "rules": [
    {
      "source_type": "question",
      "source_question_id": "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
      "operator": "less_than",
      "value": "7"
    }
  ]
}
Response

The created condition and where it routes.

FieldTypeDescription
data.iduuidCondition identifier; use it to delete the condition again.
data.question_iduuidThe question the condition is attached to.
data.rules_countintegerHow many rules the condition holds; all of them must match for it to fire.
data.target_question_iduuid|nullThe question the interview jumps to when the rules match, or null when the condition ends the interview.
data.end_immediatelybooleanWhether a match ends the interview instead of jumping.
{
  "data": {
    "id": "a3d5f7c1-4b28-49e6-90fa-8c17d2e5b634",
    "question_id": "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
    "rules_count": 1,
    "target_question_id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
    "end_immediately": false
  }
}
Errors
StatusCodeWhen
422invalid_operator_for_sourceThe operator is not valid for the source question's type — the resulting rule would be unrenderable and would fail publish validation.
422unsupported_on_interview_methodAn AI-evaluated operator (`expresses` / `not_expresses`) is used on a live voice study, where evaluating it would stall the interviewer between questions.
422invalid_targetThe routing target sits inside a randomized block, so it may not be presented to every participant.

Matrix rows cannot carry skip logic — attach it to the parent matrix question. Skip logic and optional are mutually exclusive on the same question.

Delete a skip condition #
DELETE /v2/studies/{study_id}/questions/{question_id}/skip-conditions/{condition_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Removes one skip condition and its rules from a question. To strip a question's routing entirely, delete each of its conditions.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question the condition lives on.
condition_iduuidThe skip condition to delete.
Response

`204 No Content` — empty body. The condition and its rules are gone; the question's remaining conditions still apply.

Add a display condition #
POST /v2/studies/{study_id}/questions/{question_id}/display-conditions/ proposed
Scope studies:write Credentials: user-delegated, partner

Adds a condition that decides whether a question is asked at all, evaluated before the participant reaches it. The rule objects are exactly those of a skip condition; rules inside one condition are ANDed and separate conditions are ORed.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question whose visibility this gates.
Request body

The rules that make this condition match.

FieldTypeDescription
rules required array[object] At least one rule, in the same shape as a skip condition's rules; all must match for the condition to trigger.
{
  "rules": [
    {
      "source_type": "screener",
      "source_screener_question_id": "5e2b9c48-1a07-4f36-b8d2-c94e60a17f53",
      "operator": "equals",
      "value": "Yes, weekly"
    }
  ]
}
Response

The created condition.

FieldTypeDescription
data.iduuidCondition identifier; use it to delete the condition again.
data.question_iduuidThe question the condition is attached to.
data.rules_countintegerHow many rules the condition holds; all of them must match for it to fire.
{
  "data": {
    "id": "a3d5f7c1-4b28-49e6-90fa-8c17d2e5b634",
    "question_id": "7a4e1b93-6d20-4f8c-b512-0ac9e3f47d61",
    "rules_count": 1
  }
}

The first condition added to a question sets its mode to SHOW_IF — the question is asked only when a condition matches. Flip it to SKIP_IF with condition_mode on the question if you want the inverse; never mix the two intents on one question.

Delete a display condition #
DELETE /v2/studies/{study_id}/questions/{question_id}/display-conditions/{condition_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Removes one display condition and its rules from a question. Delete each of a question's conditions to make it unconditional again.

Path parameters
NameTypeDescription
study_iduuidThe study the question belongs to.
question_iduuidThe question the condition lives on.
condition_iduuidThe display condition to delete.
Response

`204 No Content` — empty body. The condition and its rules are gone; a question with no conditions left is asked unconditionally.

List concepts #
GET /v2/studies/{study_id}/concepts/ proposed
Scope studies:read Credentials: user-delegated, partner

Lists the study's concepts, with each concept's field values and any attached stimulus. Concept IDs are unique per study, and this is where you discover them — for the endpoints below and for the concept stimulus endpoints in the Media & Stimuli chapter.

Path parameters
NameTypeDescription
study_iduuidThe study whose concepts you are reading.
Query parameters
NameTypeDescription
section_id uuid Return only the concepts in this concept-testing section.
page_size integer · default 50 Concepts per page, up to 200.
cursor string Opaque cursor from a previous page's `next_cursor`.
Response

Cursor-paginated list of concepts, ordered by their section's guide position and then by position within the section.

FieldTypeDescription
data[].iduuidConcept identifier.
data[].section_iduuidConcept-testing section the concept belongs to.
data[].namestringShort display name for the concept, e.g. "Variant A".
data[].positionintegerZero-indexed position of the concept within its section.
data[].watermark_stimulus_enabledbooleanWhether the interview's identifier is composited onto the concept's stimulus image.
data[].field_valuesarray[object]This concept's value for each of the section's concept fields, each with `field_id`, `slug`, and `value`.
data[].stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
{
  "data": [
    {
      "id": "6f0d3b28-c145-4a97-8e13-5b7a2c0f9d64",
      "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
      "name": "Variant C",
      "position": 2,
      "watermark_stimulus_enabled": true,
      "field_values": [
        {
          "field_id": "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
          "slug": "price",
          "value": "$12.99 / month"
        }
      ],
      "stimulus": null
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Create a concept #
POST /v2/studies/{study_id}/concepts/ proposed
Scope studies:write Credentials: user-delegated, partner

Adds one variant — a tagline, a pricing page, a design — to a concept-testing section. Give it content by setting its field values and, if it is visual, attaching a stimulus.

Path parameters
NameTypeDescription
study_iduuidThe study to add the concept to.
Request body

The target section, the concept's name, and its stimulus handling.

FieldTypeDescription
section_id required uuid The concept-testing section to add the concept to.
name required string Short display name for the concept, e.g. "Variant A".
watermark_stimulus_enabled boolean Composite the interview's identifier onto the concept's stimulus image so leaked screenshots are traceable (defaults to false).
{
  "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
  "name": "Variant C",
  "watermark_stimulus_enabled": true
}
Response

The created concept, in the shape returned by the list endpoint. It starts with no field values and no stimulus.

FieldTypeDescription
data.iduuidConcept identifier.
data.section_iduuidConcept-testing section the concept belongs to.
data.namestringShort display name for the concept, e.g. "Variant A".
data.positionintegerZero-indexed position of the concept within its section.
data.watermark_stimulus_enabledbooleanWhether the interview's identifier is composited onto the concept's stimulus image.
data.field_valuesarray[object]This concept's value for each of the section's concept fields, each with `field_id`, `slug`, and `value`.
data.stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
{
  "data": {
    "id": "6f0d3b28-c145-4a97-8e13-5b7a2c0f9d64",
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Variant C",
    "position": 2,
    "watermark_stimulus_enabled": true,
    "field_values": [],
    "stimulus": null
  }
}
Errors
StatusCodeWhen
404not_foundThe section exists but is not a concept-testing section, or it does not belong to this study.
Update a concept #
PATCH /v2/studies/{study_id}/concepts/{concept_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Renames a concept or changes whether its stimulus image is watermarked. Only the fields you send are changed.

Path parameters
NameTypeDescription
study_iduuidThe study the concept belongs to.
concept_iduuidThe concept to update.
Request body

Any subset of the concept's mutable fields.

FieldTypeDescription
name string New display name for the concept.
watermark_stimulus_enabled boolean Whether the interview's identifier is composited onto the concept's stimulus image.
Response

The updated concept, in the shape returned by the list endpoint.

FieldTypeDescription
data.iduuidConcept identifier.
data.section_iduuidConcept-testing section the concept belongs to.
data.namestringShort display name for the concept, e.g. "Variant A".
data.positionintegerZero-indexed position of the concept within its section.
data.watermark_stimulus_enabledbooleanWhether the interview's identifier is composited onto the concept's stimulus image.
data.field_valuesarray[object]This concept's value for each of the section's concept fields, each with `field_id`, `slug`, and `value`.
data.stimulusobject|nullAttached stimulus, if any, with a short-lived presigned URL — see the Media & Stimuli chapter.
{
  "data": {
    "id": "6f0d3b28-c145-4a97-8e13-5b7a2c0f9d64",
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Variant C",
    "position": 2,
    "watermark_stimulus_enabled": true,
    "field_values": [
      {
        "field_id": "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
        "slug": "price",
        "value": "$12.99 / month"
      }
    ],
    "stimulus": null
  }
}
Delete a concept #
DELETE /v2/studies/{study_id}/concepts/{concept_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Removes a variant from the set participants evaluate, along with its field values.

Path parameters
NameTypeDescription
study_iduuidThe study the concept belongs to.
concept_iduuidThe concept to delete.
Response

`204 No Content` — empty body. The concept and its field values are gone; the remaining concepts close the gap.

Reorder concepts #
POST /v2/studies/{study_id}/concepts/reorder/ proposed
Scope studies:write Credentials: user-delegated, partner

Sets the order of the concepts in one section. Send the complete ordered list, not a diff.

Path parameters
NameTypeDescription
study_iduuidThe study the concepts belong to.
Request body

The section being reordered and the full desired order.

FieldTypeDescription
section_id required uuid The concept-testing section whose concepts you are reordering.
concept_ids required array[uuid] Every concept in that section, exactly once, in the order you want.
{
  "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
  "concept_ids": [
    "6f0d3b28-c145-4a97-8e13-5b7a2c0f9d64",
    "c3a80e57-1f92-4d06-b7e4-8a25c9f1d370"
  ]
}
Response

The concept order as applied within the section.

FieldTypeDescription
data.section_iduuidThe concept-testing section that was reordered.
data.concept_idsarray[uuid]Every concept in that section, in its new order; the first is at position 0.
{
  "data": {
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "concept_ids": [
      "6f0d3b28-c145-4a97-8e13-5b7a2c0f9d64",
      "c3a80e57-1f92-4d06-b7e4-8a25c9f1d370"
    ]
  }
}

The authored order only reaches participants when the section's concept randomization is off.

Create a concept field #
POST /v2/studies/{study_id}/concept-fields/ proposed
Scope studies:write Credentials: user-delegated, partner

Defines an attribute every concept in a section carries — "Price", "Tagline" — which becomes a {slug} token usable in that section's question text. The field starts blank on every existing concept.

Path parameters
NameTypeDescription
study_iduuidThe study the section belongs to.
Request body

The target section plus the field's name and shape.

FieldTypeDescription
section_id required uuid The concept-testing section to define the field on.
name required string Field name, e.g. "Price"; it is slugified into the `{slug}` token you reference in question text.
field_type enum `SINGLE_LINE` (default) for short values or `MULTI_LINE` for paragraphs.
{
  "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
  "name": "Price",
  "field_type": "SINGLE_LINE"
}
Response

The created field, including the generated slug.

FieldTypeDescription
data.iduuidConcept field identifier.
data.section_iduuidConcept-testing section the field is defined on.
data.namestringField name, e.g. "Price".
data.slugstringSlugified field name; the `{slug}` token you reference in the section's question text.
data.field_typeenum`SINGLE_LINE` or `MULTI_LINE`.
data.positionintegerZero-indexed position of the field among the section's fields.
{
  "data": {
    "id": "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Price",
    "slug": "price",
    "field_type": "SINGLE_LINE",
    "position": 1
  }
}

The built-in image/video stimulus field is managed through the stimulus endpoints and cannot be created, edited, or deleted here.

Update a concept field #
PATCH /v2/studies/{study_id}/concept-fields/{field_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Renames a concept field or changes its type. Renaming regenerates the {slug} token, and the response reports any question text still referencing the old one.

Path parameters
NameTypeDescription
study_iduuidThe study the field belongs to.
field_iduuidThe field to update.
Request body

Any subset of the field's mutable attributes.

FieldTypeDescription
name string New field name; the slug is regenerated from it.
field_type enum `SINGLE_LINE` or `MULTI_LINE`.
Response

The updated field with its regenerated slug, plus the question text still pointing at the old one.

FieldTypeDescription
data.iduuidConcept field identifier.
data.section_iduuidConcept-testing section the field is defined on.
data.namestringField name, e.g. "Price".
data.slugstringSlugified field name; the `{slug}` token you reference in the section's question text.
data.field_typeenum`SINGLE_LINE` or `MULTI_LINE`.
data.positionintegerZero-indexed position of the field among the section's fields.
data.questions_referencing_old_slugarray[object]Questions in this section whose text still embeds the previous `{slug}` token, each with `id` and `text`; empty when nothing references it.
{
  "data": {
    "id": "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Monthly price",
    "slug": "monthly-price",
    "field_type": "SINGLE_LINE",
    "position": 1,
    "questions_referencing_old_slug": [
      {
        "id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
        "text": "At {price} a month, would you subscribe?"
      }
    ]
  }
}

Renaming does not rewrite question text for you — a stale {slug} token renders as an unknown value to participants until you edit the question.

Delete a concept field #
DELETE /v2/studies/{study_id}/concept-fields/{field_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Deletes a concept field definition and every concept's value for it, in one transaction.

Path parameters
NameTypeDescription
study_iduuidThe study the field belongs to.
field_iduuidThe field to delete.
Response

`200 OK`, not `204` — the deleted field as it last stood, plus the question text left pointing at its token.

FieldTypeDescription
data.iduuidConcept field identifier.
data.section_iduuidConcept-testing section the field is defined on.
data.namestringField name, e.g. "Price".
data.slugstringSlugified field name; the `{slug}` token you reference in the section's question text.
data.field_typeenum`SINGLE_LINE` or `MULTI_LINE`.
data.positionintegerZero-indexed position of the field among the section's fields.
data.questions_referencing_slugarray[object]Questions in this section whose text still embeds the deleted `{slug}` token, each with `id` and `text`; they render an unknown value until you edit them.
{
  "data": {
    "id": "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "name": "Price",
    "slug": "price",
    "field_type": "SINGLE_LINE",
    "position": 1,
    "questions_referencing_slug": [
      {
        "id": "e58c2f10-9a4b-4d77-83e6-14b0d7c5a962",
        "text": "At {price} a month, would you subscribe?"
      }
    ]
  }
}

Irreversible, and it takes every per-concept value with it. Question text referencing the token is left untouched and renders as an unknown value until you edit it.

Reorder concept fields #
POST /v2/studies/{study_id}/concept-fields/reorder/ proposed
Scope studies:write Credentials: user-delegated, partner

Sets the order of one section's concept fields. Send the complete ordered list, including the built-in stimulus field.

Path parameters
NameTypeDescription
study_iduuidThe study the fields belong to.
Request body

The section being reordered and the full desired order.

FieldTypeDescription
section_id required uuid The concept-testing section whose fields you are reordering.
field_ids required array[uuid] Every concept field on that section — the built-in stimulus field included — in the order you want.
{
  "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
  "field_ids": [
    "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
    "7c15e0a3-4b68-4f92-90d1-e6b3a4527f08"
  ]
}
Response

The field order as applied within the section, the built-in stimulus field included.

FieldTypeDescription
data.section_iduuidThe concept-testing section that was reordered.
data.field_idsarray[uuid]Every concept field on that section, in its new order; the first is at position 0.
{
  "data": {
    "section_id": "9d84b6f1-72a4-4c19-8f0b-1e5c93a7d2b8",
    "field_ids": [
      "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
      "7c15e0a3-4b68-4f92-90d1-e6b3a4527f08"
    ]
  }
}
Set a concept's field value #
PUT /v2/studies/{study_id}/concepts/{concept_id}/field-values/{field_id}/ proposed
Scope studies:write Credentials: user-delegated, partner

Sets one concept's value for one field — Variant A's price, say. The value substitutes into that field's {slug} token wherever the section's question text uses it.

Path parameters
NameTypeDescription
study_iduuidThe study the concept belongs to.
concept_iduuidThe concept to set the value on.
field_iduuidThe field whose value you are setting; it must belong to the concept's section.
Request body

The value for this concept-and-field pair.

FieldTypeDescription
value required string The value participants see in place of the token; send an empty string to clear it.
{
  "value": "$12.99 / month"
}
Response

The stored value for this concept-and-field pair.

FieldTypeDescription
data.iduuidIdentifier of the stored value row.
data.concept_iduuidThe concept the value belongs to.
data.field_iduuidThe field the value belongs to.
data.slugstringThe field's `{slug}` token this value substitutes into.
data.valuestringThe stored value, as participants will see it.
data.clearedbooleanTrue when an empty value was sent, so the token now renders as an unknown value.
{
  "data": {
    "id": "5a91c8e0-2743-4bd6-8f19-c0e73a5b1284",
    "concept_id": "6f0d3b28-c145-4a97-8e13-5b7a2c0f9d64",
    "field_id": "0ab4c7e9-53d1-46f2-9c08-7e6b1d4a3f25",
    "slug": "price",
    "value": "$12.99 / month",
    "cleared": false
  }
}

Idempotent — the value row is created on first write and replaced afterwards. The stimulus field is set through the concept stimulus endpoint, not here.

Chapter 09

Media & Stimuli #

Stimuli are the images, videos, and documents participants are shown during an interview. They live in a per-project bank, so one uploaded file can be reused across many studies, questions, and concepts in the same project. Getting a file into a study is always two steps — ask for a presigned upload, PUT the bytes straight to storage, then commit, at which point Outset validates the real bytes and mints the reusable stimulus — followed by a third step that attaches the stimulus to the question or concept that should show it. Content images are a separate, simpler surface: images embedded inside question or option markdown rather than attached as a question's stimulus.

Start a stimulus upload #
POST /v2/studies/{study_id}/stimuli/uploads/ proposed
Scope studies:write Credentials: user-delegated, partner

Ask for a presigned URL to upload one stimulus file. Outset chooses the storage key and returns a short-lived PUT URL plus the exact headers to apply; you then send the bytes directly to storage and commit the upload. Nothing about the study changes until you commit.

Path parameters
NameTypeDescription
study_idUUIDStudy whose project the uploaded file will belong to.
Request body

The file you intend to upload. Only the name is needed — the bytes never pass through this request.

FieldTypeDescription
filename required string Name of the file you are about to upload, including its extension — images (`jpg`, `jpeg`, `png`, `webp`, `gif`), video (`mp4`, `mov`, `webm`, `avi`, `mpeg`, `ogv`), or documents (`pdf`, `docx`). Any other extension is rejected.
{
  "filename": "pricing-page-v2.png"
}
Response

The upload ticket. `PUT` the file bytes to `upload_url`, applying every header in `headers` verbatim, then call the commit endpoint with `upload_id`.

FieldTypeDescription
data.upload_idUUIDIdentifier for this upload, used to commit it and never reusable once committed.
data.upload_urlstringPresigned URL to send the file bytes to with a single HTTP PUT.
data.headersarray of objectsHeaders that must be sent verbatim on the PUT — each entry has a `name` and a `value`. Omitting or altering one invalidates the signature and the upload fails.
data.expires_atdatetimeWhen the presigned URL stops working (15 minutes after issue). Request a new upload rather than retrying an expired URL.
{
  "data": {
    "upload_id": "6f2a91c4-3d5b-4e18-9a07-c81b2f6de340",
    "upload_url": "https://uploads.outset.ai/stimulus-bank/…?X-Amz-Signature=…",
    "headers": [
      {
        "name": "Content-Type",
        "value": "image/png"
      }
    ],
    "expires_at": "2026-08-11T14:32:05Z"
  }
}
Errors
StatusCodeWhen
400validation_errorThe filename has no extension, or an extension outside the supported image / video / document set.

Outset chooses the storage key; clients never name their own. The stored content type is derived from the extension rather than from anything you send, so a mismatch between the extension and the real bytes is caught at commit, not here. Files up to the storage limit are fine — the bytes go straight to storage and never traverse the API.

Commit an uploaded stimulus #
POST /v2/studies/{study_id}/stimuli/uploads/{upload_id}/complete/ proposed
Scope studies:write Credentials: user-delegated, partner

Confirm that the bytes have landed. Outset reads the stored object, validates that its real content matches the declared file type, and creates the reusable stimulus in the study's project. The returned stimulus_id is what you attach to a question or concept.

Path parameters
NameTypeDescription
study_idUUIDStudy the upload was issued for.
upload_idUUIDUpload ticket returned when the upload was started.
Request body

Empty — the commit call takes no fields. Outset validates the bytes that actually landed at the presigned URL.

Response

The created stimulus, now part of the project's reusable bank.

FieldTypeDescription
data.idUUIDStimulus identifier, used to attach this asset to questions and concepts.
data.filenamestringOriginal file name as uploaded.
data.content_typestringMedia type detected from the stored bytes, e.g. `image/png`.
data.descriptionstringOutset-generated description of the asset's visual content, used to give the AI interviewer context about what participants are being shown. Empty until it has been generated.
data.file_urlstringShort-lived URL for viewing or downloading the file. Expires within minutes and must not be stored.
{
  "data": {
    "id": "b47c0e91-8f2d-4a63-91be-5d0c7a1f3e88",
    "filename": "pricing-page-v2.png",
    "content_type": "image/png",
    "description": "A pricing page with three plan columns; the middle plan is highlighted as the recommended one.",
    "file_url": "https://files.outset.ai/stimulus-bank/…?X-Amz-Signature=…"
  }
}
Errors
StatusCodeWhen
400validation_errorNo object was found for this upload — the presigned URL expired before the PUT, or the PUT never succeeded.
400validation_errorThe stored bytes do not match the declared file type. Password-protected and sensitivity-labelled Word documents fail here specifically and cannot be used as stimuli.

Validation happens against the real bytes, not the filename you declared — an upload that passes the start call can still be rejected here. Commit is single-use: once committed, the upload ticket is spent and re-committing returns 404.

List the project's stimuli #
GET /v2/studies/{study_id}/stimuli/ proposed
Scope studies:read Credentials: user-delegated, partner

List every reusable stimulus in the project the study belongs to, newest first, with a short-lived view URL and the questions, concepts, and sections currently using each one. Use it to reuse an asset instead of uploading it again.

Path parameters
NameTypeDescription
study_idUUIDStudy whose project's stimulus bank is listed. The bank is per project, so sibling studies in the same project see the same assets.
Query parameters
NameTypeDescription
page_size integer · default 50 Rows per page, maximum 200.
cursor string Opaque cursor from a previous page's `next_cursor`.
Response

Cursor-paginated stimuli, newest first.

FieldTypeDescription
data[].idUUIDStimulus identifier, used when attaching it to a question or concept.
data[].filenamestringOriginal file name, or `null` for a stimulus captured from a web page rather than uploaded.
data[].descriptionstringOutset-generated description of the asset's visual content, used to give the AI interviewer context about what participants are being shown. Empty until it has been generated.
data[].content_typestringMedia type of the stored file, e.g. `image/png` or `video/mp4`.
data[].capture_urlstringWeb page this stimulus was captured from, or `null` when the file was uploaded directly.
data[].file_urlstringShort-lived URL for viewing or downloading the file; `null` while a page capture is still rendering. Expires within minutes and must not be stored.
data[].used_by[]array of objectsWhere this stimulus is currently shown: each entry has `type` (`question`, `concept`, or `section`), `id`, and a human-readable `label`. Empty when the asset is not attached anywhere.
{
  "data": [
    {
      "id": "b47c0e91-8f2d-4a63-91be-5d0c7a1f3e88",
      "filename": "pricing-page-v2.png",
      "description": "A pricing page with three plan columns; the middle plan is highlighted as the recommended one.",
      "content_type": "image/png",
      "capture_url": null,
      "file_url": "https://files.outset.ai/stimulus-bank/…?X-Amz-Signature=…",
      "used_by": [
        {
          "type": "question",
          "id": "1d9a4c27-7b30-4e52-8c11-6ae2f905b7d4",
          "label": "Q4"
        }
      ]
    }
  ],
  "next_cursor": "cD0yMDI2LTA4LTExKzE0JTNBMzAlM0Ew",
  "has_more": true
}

The stimulus bank is scoped to the project, not the study — an asset uploaded for one study is visible to every study in the same project, and detaching it from a question does not remove it from the bank.

Retrieve a stimulus #
GET /v2/studies/{study_id}/stimuli/{stimulus_id}/ proposed
Scope studies:read Credentials: user-delegated, partner

Read one stimulus, including a freshly signed file_url. Use this to re-sign a download link that has expired without paging through the whole bank.

Path parameters
NameTypeDescription
study_idUUIDStudy whose project owns the stimulus.
stimulus_idUUIDStimulus to read.
Response

The stimulus, in the same shape as a row of the list endpoint.

FieldTypeDescription
data.idUUIDStimulus identifier, used when attaching it to a question or concept.
data.filenamestringOriginal file name, or `null` for a stimulus captured from a web page rather than uploaded.
data.descriptionstringOutset-generated description of the asset's visual content, used to give the AI interviewer context about what participants are being shown. Empty until it has been generated.
data.content_typestringMedia type of the stored file, e.g. `image/png` or `video/mp4`.
data.capture_urlstringWeb page this stimulus was captured from, or `null` when the file was uploaded directly.
data.file_urlstringShort-lived URL for viewing or downloading the file; `null` while a page capture is still rendering. Expires within minutes and must not be stored.
data.used_by[]array of objectsWhere this stimulus is currently shown: each entry has `type` (`question`, `concept`, or `section`), `id`, and a human-readable `label`. Empty when the asset is not attached anywhere.
{
  "data": {
    "id": "b47c0e91-8f2d-4a63-91be-5d0c7a1f3e88",
    "filename": "pricing-page-v2.png",
    "description": "A pricing page with three plan columns; the middle plan is highlighted as the recommended one.",
    "content_type": "image/png",
    "capture_url": null,
    "file_url": "https://files.outset.ai/stimulus-bank/…?X-Amz-Signature=…",
    "used_by": [
      {
        "type": "question",
        "id": "1d9a4c27-7b30-4e52-8c11-6ae2f905b7d4",
        "label": "Q4"
      }
    ]
  }
}
Errors
StatusCodeWhen
404not_foundThe stimulus belongs to a different project, or to a workspace outside the credential's grant.
Attach a stimulus to a question #
PATCH /v2/studies/{study_id}/questions/{question_id}/stimulus/ proposed
Scope studies:write Credentials: user-delegated, partner

Set what participants see alongside a question — an asset already in the project bank, a web page for Outset to capture, or whatever another question in the same study is showing. Replaces any stimulus currently attached.

Path parameters
NameTypeDescription
study_idUUIDStudy that owns the question.
question_idUUIDQuestion to attach the stimulus to.
Request body

Exactly one source must be given; sending none or more than one is a 400.

FieldTypeDescription
stimulus_id UUID An existing stimulus in this study's project — from a committed upload or from the list endpoint.
capture_url string Public web page for Outset to screenshot and use as the stimulus. Only pass a URL a person actually supplied.
source_question_id UUID Another question in the same study whose stimulus should be reused here.
{
  "stimulus_id": "b47c0e91-8f2d-4a63-91be-5d0c7a1f3e88"
}
Response

The question's stimulus after the change.

FieldTypeDescription
data.question_idUUIDQuestion that was updated.
data.stimulusobjectThe attached stimulus: `id`, `filename`, `content_type`, `capture_url`, and a short-lived `file_url`.
{
  "data": {
    "question_id": "1d9a4c27-7b30-4e52-8c11-6ae2f905b7d4",
    "stimulus": {
      "id": "b47c0e91-8f2d-4a63-91be-5d0c7a1f3e88",
      "filename": "pricing-page-v2.png",
      "content_type": "image/png",
      "capture_url": null,
      "file_url": "https://files.outset.ai/stimulus-bank/…?X-Amz-Signature=…"
    }
  }
}
Errors
StatusCodeWhen
400validation_errorThe question is inside a concept-testing section — those questions carry no stimulus; attach to the concept instead.
400validation_error`capture_url` points at an internal or otherwise unroutable host. Only publicly reachable pages can be captured.
400validation_error`source_question_id` names a question with no stimulus to copy.

Attaching is a pointer, not a copy: the same stimulus can back many questions, and its used_by list on the read endpoints is how you see where else it appears. A capture_url attach renders the screenshot server-side, so the stimulus exists immediately but its file_url stays null until the render lands — poll the stimulus until it is populated. HTML co-design files are a restricted case: they attach only to co-design questions, and only for organizations with that capability enabled.

Remove a question's stimulus #
DELETE /v2/studies/{study_id}/questions/{question_id}/stimulus/ proposed
Scope studies:write Credentials: user-delegated, partner

Detach whatever stimulus the question is showing. The asset itself stays in the project's stimulus bank and any other question or concept using it is unaffected.

Path parameters
NameTypeDescription
study_idUUIDStudy that owns the question.
question_idUUIDQuestion to detach the stimulus from.
Response

204 — empty body. Detaching a question that has no stimulus succeeds unchanged, and the asset stays in the project's stimulus bank.

Attach a stimulus to a concept #
PATCH /v2/studies/{study_id}/concepts/{concept_id}/stimulus/ proposed
Scope studies:write Credentials: user-delegated, partner

Set the asset a concept in a concept-testing section is evaluated against — an existing stimulus, a web page to capture, or the stimulus already on another concept in the same study. Replaces any stimulus currently attached.

Path parameters
NameTypeDescription
study_idUUIDStudy that owns the concept-testing section.
concept_idUUIDConcept to attach the stimulus to.
Request body

Exactly one source must be given; sending none or more than one is a 400.

FieldTypeDescription
stimulus_id UUID An existing stimulus in this study's project — from a committed upload or from the list endpoint.
capture_url string Public web page for Outset to screenshot and use as the stimulus. Only pass a URL a person actually supplied.
source_concept_id UUID Another concept in the same study whose stimulus should be reused here.
{
  "capture_url": "https://example.com/pricing"
}
Response

The concept's stimulus after the change.

FieldTypeDescription
data.concept_idUUIDConcept that was updated.
data.section_idUUIDConcept-testing section the concept belongs to.
data.stimulusobjectThe attached stimulus: `id`, `filename`, `content_type`, `capture_url`, and a short-lived `file_url`, as documented on the stimulus read endpoint.
{
  "data": {
    "concept_id": "7c3f5b18-2a94-4d07-b6e1-8f20c95a4d63",
    "section_id": "0e6b21d5-4c78-49af-9310-52d7fa8c1b09",
    "stimulus": {
      "id": "a92d6f30-5c14-4b78-8e02-63f1c7a05d29",
      "filename": null,
      "content_type": "image/png",
      "capture_url": "https://example.com/pricing",
      "file_url": null
    }
  }
}
Errors
StatusCodeWhen
400validation_errorThe stimulus is an HTML co-design file — those attach only to co-design questions, never to concepts.
400validation_error`source_concept_id` names a concept with no stimulus to copy.

A capture_url attach on a concept always renders asynchronously: the concept points at the new stimulus straight away, but its file_url is null until the screenshot completes.

Remove a concept's stimulus #
DELETE /v2/studies/{study_id}/concepts/{concept_id}/stimulus/ proposed
Scope studies:write Credentials: user-delegated, partner

Detach whatever stimulus the concept is showing. The asset stays in the project's stimulus bank and other concepts using it are unaffected.

Path parameters
NameTypeDescription
study_idUUIDStudy that owns the concept-testing section.
concept_idUUIDConcept to detach the stimulus from.
Response

204 — empty body. Detaching a concept that has no stimulus succeeds unchanged, and the asset stays in the project's stimulus bank.

Upload an image to embed in question content #
POST /v2/studies/{study_id}/content-images/uploads/ proposed
Scope studies:write Credentials: user-delegated, partner

Get a presigned URL for an image you want to embed inside a question's wording or an answer option's label, plus the durable URL to reference it by. Distinct from a stimulus, which is attached to the question as a whole rather than placed within its text.

Path parameters
NameTypeDescription
study_idUUIDStudy whose content the image will be embedded in.
Request body

The image you intend to upload, plus the alt text for the generated markdown snippet.

FieldTypeDescription
filename required string Name of the image file including its extension — `jpg`, `jpeg`, `png`, `webp`, or `gif`. Other types are rejected.
alt_text string Alt text used in the returned markdown snippet; defaults to empty.
{
  "filename": "scale-diagram.png",
  "alt_text": "Seven-point satisfaction scale"
}
Response

The upload ticket plus the URL the image will be served from once the PUT lands.

FieldTypeDescription
data.upload_urlstringPresigned URL to send the image bytes to with a single HTTP PUT.
data.headersarray of objectsHeaders that must be sent verbatim on the PUT — each entry has a `name` and a `value`.
data.urlstringDurable URL of the embedded image. It does not expire and is safe to store inside question or option content.
data.markdownstringReady-to-paste markdown snippet — `![alt_text](url)` — to splice into a question's wording or an option's label.
data.expires_atdatetimeWhen `upload_url` stops working (15 minutes after issue). Request a new upload rather than retrying an expired URL.
{
  "data": {
    "upload_url": "https://uploads.outset.ai/editor-images/…?X-Amz-Signature=…",
    "headers": [
      {
        "name": "Content-Type",
        "value": "image/png"
      }
    ],
    "url": "https://media.outset.ai/editor-images/scale-diagram-a41f9c.png",
    "markdown": "![Seven-point satisfaction scale](https://media.outset.ai/editor-images/scale-diagram-a41f9c.png)",
    "expires_at": "2026-08-11T14:32:05Z"
  }
}
Errors
StatusCodeWhen
400validation_errorThe filename has no extension, or an extension outside the supported image set.

Unlike stimulus uploads there is no commit step — url resolves as soon as your PUT completes, and the image is not validated beyond its declared type. The upload alone changes nothing: the image only appears to participants once you put url (or the markdown snippet) into a question's text or an option's label through the study-content endpoints. Content images are served from a durable public URL rather than a signed one, so treat anything you upload here as publicly readable by whoever holds the link.

Chapter 10

Languages & Translations #

A study is written in one source language and can offer participants a curated set of additional languages, each translated by Outset and tracked through its own generation and review lifecycle. The flow is always the same three steps: declare the offered set, generate translations, then (optionally) review each language before the study publishes. Reads need studies:read; every write here is studies:write, and the two destructive ones — replacing the offered set and repinning the source language — are refused while the study is published.

Every offered language follows one lifecycle: QUEUED (never generated, or stale after a content edit) → RUNNING (generating) → GENERATED, REVIEWED, or FAILED. The endpoints below use those states throughout.

Get a study's language configuration #
GET /v2/studies/{study_id}/languages/ proposed
Scope studies:read Credentials: user-delegated, partner

Returns the study's source language plus every language it offers participants, each with its translation status and review settings. Poll this after triggering generation until every language reads GENERATED, REVIEWED, or FAILED.

Path parameters
NameTypeDescription
study_iduuidThe study whose language configuration to read.
Response

The study's language configuration as a single object.

FieldTypeDescription
data.written_language_codestringThe language the guide, screener, and other study content are written in — the source every other language is translated from.
data.base_language_codestringThe language used for recruitment copy and as the participant-facing default; empty means it falls back to the written language. Set on the study resource, not here.
data.language_modestringHow participant languages are offered: `CURATED` (a researcher-curated, pre-generated set) is the only implemented mode; `OPEN` is reserved for a future lazy-generation mode.
data.languages[].language_codestringISO 639-1 code, or a BCP-47 regional variant such as `pt-BR` or `zh-Hant`.
data.languages[].statusstringOne of `QUEUED` (never generated, or stale after a content edit), `RUNNING` (generating), `GENERATED`, `REVIEWED`, or `FAILED`.
data.languages[].review_requiredbooleanWhether this language must be marked reviewed before the study can publish.
data.languages[].reviewed_atdatetimeWhen the language was last marked reviewed, or null if it never was.
data.languages[].translation_contextstringThe glossary and formality brief Outset generated for this language to keep its per-item translations consistent; empty until translations are generated.
data.languages[].interviewer_voice_enginestringPer-language interviewer voice engine, `SIMPLE` or `ADVANCED`; null means the language inherits the study-level voice.
data.languages[].interviewer_voice_idstringThe picked voice for an `ADVANCED` engine, or null when none is set.
{
  "data": {
    "written_language_code": "en",
    "base_language_code": "",
    "language_mode": "CURATED",
    "languages": [
      {
        "language_code": "en",
        "status": "REVIEWED",
        "review_required": false,
        "reviewed_at": "2026-07-02T14:21:09Z",
        "translation_context": "",
        "interviewer_voice_engine": null,
        "interviewer_voice_id": null
      },
      {
        "language_code": "de",
        "status": "GENERATED",
        "review_required": true,
        "reviewed_at": null,
        "translation_context": "Formal register (Sie). Keep the product name \"Northwind\" untranslated.",
        "interviewer_voice_engine": "ADVANCED",
        "interviewer_voice_id": "a1f7b23c-6d90-4c11-8e42-9b0d5f6a7c31"
      },
      {
        "language_code": "ja",
        "status": "QUEUED",
        "review_required": false,
        "reviewed_at": null,
        "translation_context": "",
        "interviewer_voice_engine": "SIMPLE",
        "interviewer_voice_id": null
      }
    ]
  }
}

The source language appears in languages only when the study offers its own source text to participants. That row carries no translations, so it is always ready and never needs review.

Replace the offered language set #
PUT /v2/studies/{study_id}/languages/ proposed
Scope studies:write Credentials: user-delegated, partner

Declaratively sets the languages the study offers participants. Send the full set on every call: languages you omit are removed, and newly added ones start at QUEUED until you trigger generation. Include the study's written language in the list to also offer participants the untranslated source text.

Path parameters
NameTypeDescription
study_iduuidThe study whose offered languages to replace.
Request body

The complete set of languages the study should offer.

FieldTypeDescription
languages required array The full set of language codes to offer, at least one and at most 50; each must be a language Outset supports (a base ISO 639-1 code, or a regional variant such as `pt-BR`, `es-419`, `fr-CA`, `de-CH`, `zh-Hant`).
{
  "languages": [
    "en",
    "de",
    "ja"
  ]
}
Response

The study's language configuration after the change, in the same shape as the GET.

FieldTypeDescription
data.written_language_codestringThe study's source language, unchanged by this call.
data.base_language_codestringThe language used for recruitment copy and as the participant-facing default; empty means it falls back to the written language. Set on the study resource, not here — the two differ when recruitment copy is deliberately fielded in another language.
data.language_modestringHow participant languages are offered: `CURATED` (a researcher-curated, pre-generated set) is the only implemented mode; `OPEN` is reserved for a future lazy-generation mode.
data.languages[]arrayThe resulting offered languages. Each carries `language_code`, `status`, `review_required`, `reviewed_at`, `translation_context`, `interviewer_voice_engine` and `interviewer_voice_id`, itemized under Get a study's language configuration.
{
  "data": {
    "written_language_code": "en",
    "base_language_code": "",
    "language_mode": "CURATED",
    "languages": [
      {
        "language_code": "en",
        "status": "REVIEWED",
        "review_required": false,
        "reviewed_at": "2026-07-02T14:21:09Z",
        "translation_context": "",
        "interviewer_voice_engine": null,
        "interviewer_voice_id": null
      },
      {
        "language_code": "de",
        "status": "GENERATED",
        "review_required": true,
        "reviewed_at": null,
        "translation_context": "Formal register (Sie). Keep the product name \"Northwind\" untranslated.",
        "interviewer_voice_engine": "ADVANCED",
        "interviewer_voice_id": "a1f7b23c-6d90-4c11-8e42-9b0d5f6a7c31"
      },
      {
        "language_code": "ja",
        "status": "QUEUED",
        "review_required": false,
        "reviewed_at": null,
        "translation_context": "",
        "interviewer_voice_engine": "SIMPLE",
        "interviewer_voice_id": null
      }
    ]
  }
}
Errors
StatusCodeWhen
400unsupported_language_codeOne of the codes is not a language Outset supports.
400validation_errorThe list is empty — a study must always offer at least one language.
409published_studyThe study is published. Unpublish it, change languages, then publish again.

Removing a language deletes its generated translations, and that cannot be undone — re-adding the language starts it from QUEUED and costs a fresh generation run. Each newly added language is also seeded with a voice appropriate to it rather than inheriting the study-level (source-language) voice. If the study's base language is not in the set you send, it is cleared and falls back to the written language.

Set the study's source language #
PUT /v2/studies/{study_id}/languages/source/ proposed
Scope studies:write Credentials: user-delegated, partner

Declares the language the study is written in. Outset normally detects this from the guide's own text, so use this only to correct a wrong detection or a study authored in a language other than the detected one.

Path parameters
NameTypeDescription
study_iduuidThe study whose source language to set.
Request body

The language the study content is written in.

FieldTypeDescription
language_code required string The language code the study is written in; must be a language Outset supports.
{
  "language_code": "ja"
}
Response

The study's language configuration after the change, in the same shape as the GET. The new source language reads REVIEWED, because source text needs no translation.

FieldTypeDescription
data.written_language_codestringThe language the guide, screener, and other study content are written in — the source every other language is translated from.
data.base_language_codestringThe language used for recruitment copy and as the participant-facing default; empty means it falls back to the written language. Set on the study resource, not here.
data.language_modestringHow participant languages are offered: `CURATED` (a researcher-curated, pre-generated set) is the only implemented mode; `OPEN` is reserved for a future lazy-generation mode.
data.languages[].language_codestringISO 639-1 code, or a BCP-47 regional variant such as `pt-BR` or `zh-Hant`.
data.languages[].statusstringOne of `QUEUED` (never generated, or stale after a content edit), `RUNNING` (generating), `GENERATED`, `REVIEWED`, or `FAILED`.
data.languages[].review_requiredbooleanWhether this language must be marked reviewed before the study can publish.
data.languages[].reviewed_atdatetimeWhen the language was last marked reviewed, or null if it never was.
data.languages[].translation_contextstringThe glossary and formality brief Outset generated for this language to keep its per-item translations consistent; empty until translations are generated.
data.languages[].interviewer_voice_enginestringPer-language interviewer voice engine, `SIMPLE` or `ADVANCED`; null means the language inherits the study-level voice.
data.languages[].interviewer_voice_idstringThe picked voice for an `ADVANCED` engine, or null when none is set.
{
  "data": {
    "written_language_code": "ja",
    "base_language_code": "",
    "language_mode": "CURATED",
    "languages": [
      {
        "language_code": "ja",
        "status": "REVIEWED",
        "review_required": false,
        "reviewed_at": "2026-08-11T09:41:26Z",
        "translation_context": "",
        "interviewer_voice_engine": "SIMPLE",
        "interviewer_voice_id": null
      },
      {
        "language_code": "en",
        "status": "QUEUED",
        "review_required": false,
        "reviewed_at": null,
        "translation_context": "",
        "interviewer_voice_engine": null,
        "interviewer_voice_id": null
      }
    ]
  }
}
Errors
StatusCodeWhen
400unsupported_language_codeThe code is not a language Outset supports.
409published_studyThe study is published. Unpublish it, change the source language, then publish again.

Destructive and irreversible. Everything else is translated from the source, so any translations already generated for the language you name here are deleted (source text is served directly instead). This call also pins the source language permanently: automatic detection will never override it afterward.

Generate translations #
POST /v2/studies/{study_id}/translations/generate/ proposed
Scope studies:write Credentials: user-delegated, partner

Kicks off translation of the study's guide, screener, concepts, upload instructions, and welcome/end messages into every offered language that needs work. Returns 202 immediately with the study's language configuration; each affected language flips to RUNNING and settles at GENERATED or FAILED. Poll GET /v2/studies/{study_id}/languages/ to follow progress.

Path parameters
NameTypeDescription
study_iduuidThe study whose translations to generate.
Response

The study's language configuration as of the kickoff, in the same shape as the GET. There is no separate job handle: `GET /v2/studies/{study_id}/languages/` is the poll surface.

FieldTypeDescription
data.written_language_codestringThe study's source language, unchanged by this call.
data.languages[]arrayThe study's languages at kickoff, itemized under Get a study's language configuration — the languages picked up for this run read `RUNNING`.
{
  "data": {
    "written_language_code": "en",
    "base_language_code": "",
    "language_mode": "CURATED",
    "languages": [
      {
        "language_code": "en",
        "status": "REVIEWED",
        "review_required": false,
        "reviewed_at": "2026-07-02T14:21:09Z",
        "translation_context": "",
        "interviewer_voice_engine": null,
        "interviewer_voice_id": null
      },
      {
        "language_code": "de",
        "status": "RUNNING",
        "review_required": true,
        "reviewed_at": null,
        "translation_context": "",
        "interviewer_voice_engine": "ADVANCED",
        "interviewer_voice_id": "a1f7b23c-6d90-4c11-8e42-9b0d5f6a7c31"
      },
      {
        "language_code": "ja",
        "status": "RUNNING",
        "review_required": false,
        "reviewed_at": null,
        "translation_context": "",
        "interviewer_voice_engine": "SIMPLE",
        "interviewer_voice_id": null
      }
    ]
  }
}
Errors
StatusCodeWhen
400not_multilingual_studyThe study offers no language other than the one it is written in, so there is nothing to translate.
503source_language_detection_unavailableThe study's source language has not been detected yet and detection is temporarily unavailable. Retry; generating against the wrong source would be permanent.

Per-language progress is authoritative on GET /v2/studies/{study_id}/languages/ — poll that until each language reads GENERATED, REVIEWED, or FAILED. Generation is incremental: languages already GENERATED or REVIEWED are skipped unless a content edit made them stale (which flips them back to QUEUED), and only the edited items are re-translated. Repeated calls therefore cost only the outstanding work, but each run does consume model capacity — don't poll by re-triggering. Retrying a FAILED language is just another call to this endpoint.

Update one language's settings #
PATCH /v2/studies/{study_id}/languages/{language_code}/ proposed
Scope studies:write Credentials: user-delegated, partner

Updates a single offered language's review requirement and interviewer voice override. Omitted fields keep their current value.

Path parameters
NameTypeDescription
study_iduuidThe study the language belongs to.
language_codestringThe offered language to update, e.g. `de`.
Request body

The settings to change; send only the fields you want to move.

FieldTypeDescription
review_required boolean Whether this language must be marked reviewed before the study can publish.
interviewer_voice_engine string `SIMPLE` for the default voice for the language, `ADVANCED` to pick a voice from the catalog, or null to clear the override and inherit the study-level voice.
interviewer_voice_id string The catalog voice to speak this language, only meaningful with an `ADVANCED` engine; null or empty clears it.
{
  "review_required": true
}
Response

The updated language.

FieldTypeDescription
data.language_codestringThe language that was updated.
data.statusstringIts translation status, unchanged by this call.
data.review_requiredbooleanWhether the language now blocks publishing until it is marked reviewed.
data.interviewer_voice_enginestringThe resulting voice engine, or null when the language inherits the study-level voice.
data.interviewer_voice_idstringThe resulting voice, or null.
{
  "data": {
    "language_code": "de",
    "status": "GENERATED",
    "review_required": true,
    "interviewer_voice_engine": "ADVANCED",
    "interviewer_voice_id": "a1f7b23c-6d90-4c11-8e42-9b0d5f6a7c31"
  }
}
Errors
StatusCodeWhen
404not_foundThe study does not offer that language.

Setting review_required on the study's own written language is a no-op — source text carries no translations to review, so the flag stays false. Switching the engine to SIMPLE, or clearing it, also clears any voice previously picked for this language unless you set a new one in the same request.

Mark a language reviewed #
POST /v2/studies/{study_id}/languages/{language_code}/review/ proposed
Scope studies:write Credentials: user-delegated, partner

Records that a generated language has been checked, moving it from GENERATED to REVIEWED and clearing the publish gate for languages that require review. Calling it on an already-reviewed language succeeds and changes nothing.

Path parameters
NameTypeDescription
study_iduuidThe study the language belongs to.
language_codestringThe offered language to mark reviewed, e.g. `de`.
Response

The reviewed language, carrying the same per-language fields itemized under Get a study's language configuration.

FieldTypeDescription
data.language_codestringThe language that was marked reviewed.
data.statusstring`REVIEWED` after a successful call.
data.reviewed_atdatetimeWhen the language was marked reviewed.
data.review_requiredbooleanWhether this language blocks publishing until reviewed.
{
  "data": {
    "language_code": "de",
    "status": "REVIEWED",
    "reviewed_at": "2026-08-11T09:34:52Z",
    "review_required": true,
    "translation_context": "Formal register (Sie). Keep the product name \"Northwind\" untranslated.",
    "interviewer_voice_engine": "ADVANCED",
    "interviewer_voice_id": "a1f7b23c-6d90-4c11-8e42-9b0d5f6a7c31"
  }
}
Errors
StatusCodeWhen
404not_foundThe study does not offer that language.
409language_not_generatedThe language is still `QUEUED`, `RUNNING`, or `FAILED` — only a `GENERATED` language can be marked reviewed.
409language_has_missing_translationsThe language's translations are incomplete. Run generation again, then retry.

A review mark is per item as well as per language: editing the study content a language was translated from makes the affected items stale again, which can drop the language back to QUEUED and require a fresh generate-and-review pass before publishing.

Chapter 11

Recruitment & Fielding #

Fielding a study means answering three questions: who should take part, how many of them, and who pays. Outset supports two answers. Panel recruitment buys participants from a third-party provider (Prolific, UserInterviews, or Respondent) against your organization's recruitment wallet — you write participant-facing copy, pick audience filters, check an estimate, and launch. Self recruitment means you bring your own audience: you share the study link yourself, or you upload an email list and Outset sends the invites. Everything below hangs off one singleton resource, /v2/studies/{study_id}/recruitment/, which carries the chosen method, the panel draft, and the self-recruit configuration together. Launching or expanding recruitment spends real money and is deliberately gated behind its own studies:launch scope.

Read a study's recruitment state #
GET /v2/studies/{study_id}/recruitment/ proposed
Scope studies:read Credentials: user-delegated, partner

Read everything about how this study is being fielded: the chosen recruitment method, the panel recruitment draft (or the live recruitment, once launched), and the self-recruit invite configuration. This is the endpoint to poll after a launch to confirm the recruitment reached the provider.

Path parameters
NameTypeDescription
study_idUUIDStudy whose recruitment state you want to read.
Response

The study's recruitment singleton. `panel` is `null` until a panel draft is configured; `self_recruit` is `null` unless your organization has email-invite recruitment enabled.

FieldTypeDescription
data.study_idUUIDStudy this recruitment configuration belongs to.
data.methodstringHow this study recruits: `outset` (buy participants from a panel provider) or `self` (you share the link or invite your own audience). `null` until a method is chosen; a study cannot be published without one.
data.study_activebooleanWhether the study is currently published and accepting participants.
data.panelobject, nullableThe panel recruitment draft, or the live recruitment once launched. `null` when no panel recruitment has ever been configured for this study.
data.panel.idUUIDIdentifier of this study's panel recruitment.
data.panel.launch_statestringLifecycle of the recruitment: `DRAFT` (never launched), `LAUNCHING` (charged, provider study being created), `LIVE` (running on the provider), `FAILED` (charged but the provider call never landed — retry the launch, which does not re-charge), or `CLOSED` (the recruitment was refunded — the draft is terminal and cannot be edited or relaunched). Read this rather than inferring launched-ness from `provider_recruitment_id`.
data.panel.is_activebooleanWhether this recruitment is currently open on the provider.
data.panel.providerstringPanel provider fielding this study: `PROLIFIC`, `USERINTERVIEWS`, or `RESPONDENT`.
data.panel.provider_recruitment_idstring, nullableThe provider's own identifier for the created recruitment, once it exists.
data.panel.titlestringParticipant-facing study title, shown to panelists on the provider.
data.panel.descriptionstringParticipant-facing study description, shown to panelists on the provider.
data.panel.translated_titlestring, nullableThe participant-facing title in the study's target language; `null` when it has not been translated yet.
data.panel.translated_descriptionstring, nullableThe participant-facing description in the study's target language; `null` when it has not been translated yet.
data.panel.target_participantsintegerHow many participants this recruitment is buying.
data.panel.reward_usdstring, nullablePer-participant reward in USD, as a decimal string. Serialized as a string so no precision is lost in transit.
data.panel.total_cost_usdstring, nullableUp-front charge at launch, in USD. This figure excludes screen-out fees — request a cost estimate for the all-in expected spend.
data.panel.duration_secondsinteger, nullableEstimated interview length used to price the reward, in seconds.
data.panel.screen_out_limit_usdstring, nullableMaximum total USD this study will spend paying participants who screen out. The study pauses automatically once screen-out spend reaches it.
data.panel.screen_out_spend_usdstring, nullableScreen-out spend charged so far against that limit. `null` on providers that do not charge for screen-outs.
data.panel.block_previous_participantsbooleanWhether people already recruited into a previously-launched study in the same project are excluded from this one. Always `false` on providers that cannot exclude previous participants.
data.panel.filtersarray of objectsAudience filters currently applied to this recruitment, in the same shape the filter-selection endpoint accepts.
data.panel.provider_capabilitiesobjectWhat this provider can and cannot do — whether rewards or participant counts can be edited after launch, whether it charges for screen-outs, per-filter selection limits, and so on. Branch on these flags rather than on the provider name.
data.self_recruitobject, nullableEmail-invite configuration for a self-recruited audience. `null` when your organization does not have email-invite recruitment enabled.
data.self_recruit.invite_email_template.fieldsobjectThe resolved invite copy — `subject`, `greeting`, `body`, `cta`, `signoff`, and the optional `researcher_name` / `researcher_email` contact.
data.self_recruit.invite_email_template.sourcestringWhich tier supplied the resolved copy: this study's own saved copy, or Outset's standard template.
data.self_recruit.invite_email_template.defaultsobjectOutset's standard template copy, so a client can offer a reset to defaults.
data.self_recruit.audience_stats.recipients_readyintegerValid, de-duplicated recipients on this study's audience that have not been invited yet.
data.self_recruit.audience_stats.new_invitesintegerRecipients queued to receive an invite on the next send.
data.self_recruit.audience_stats.invitations_sentintegerInvites already sent for this study.
data.self_recruit.audience_stats.target_participantsintegerHow many completed interviews this study is aiming for.
data.self_recruit.audience_uploadsarray of objectsOne entry per uploaded audience, each with `upload_id`, `original_filename`, `row_count`, `valid_count`, `duplicate_count`, `sent_count`, `response_count`, `status`, and the resolved `email_column` / `name_column` / `metadata_columns`.
data.self_recruit.invite_followup_enabledbooleanWhether this study sends the 24-hour reminder to recipients who never started. Read-only — the reminder is off by default and only Outset can switch it on.
{
  "data": {
    "study_id": "0d3c9c7a-4b21-4f6e-9a03-6bb4f1c9a7d2",
    "method": "outset",
    "study_active": true,
    "panel": {
      "id": "b71f2e58-2c9d-4a15-8f30-1de6c4a90b77",
      "launch_state": "LIVE",
      "is_active": true,
      "provider": "PROLIFIC",
      "provider_recruitment_id": "66b1c0d4e2f39a0d5c118e42",
      "title": "20-minute chat about your grocery shopping",
      "description": "Tell us how you plan and shop for weekly groceries. Runs in your browser, no camera needed.",
      "translated_title": null,
      "translated_description": null,
      "target_participants": 120,
      "reward_usd": "4.50",
      "total_cost_usd": "702.00",
      "duration_seconds": 1200,
      "screen_out_limit_usd": "200.00",
      "screen_out_spend_usd": "18.00",
      "block_previous_participants": true,
      "filters": [
        {
          "id": "age",
          "min": "25",
          "max": "54"
        },
        "… one entry per applied filter"
      ],
      "provider_capabilities": {
        "can_edit_reward_after_publish": true,
        "charges_for_screenouts": true,
        "…": "remaining provider capability flags"
      }
    },
    "self_recruit": null
  }
}

total_cost_usd is the up-front charge only. For the all-in figure to quote to a stakeholder — including projected screen-out fees — request a cost estimate instead.

Configure recruitment #
PATCH /v2/studies/{study_id}/recruitment/ proposed
Scope studies:write Credentials: user-delegated, partner

Choose the recruitment method and, for panel recruitment, write the participant-facing copy and set the target participant count, provider, and payout options. Omitted fields are left unchanged. This only edits the draft — nothing is charged and nobody is recruited until you launch.

Path parameters
NameTypeDescription
study_idUUIDStudy to configure recruitment for.
Request body

Recruitment settings to apply. Send `method: "self"` on its own to record that you will recruit your own audience; sending any panel field creates or updates the panel draft and sets the method to `outset` for you.

FieldTypeDescription
method string How this study recruits: `self` (you share the link with your own audience, nothing is charged) or `outset` (panel recruitment). `outset` is only accepted when a panel draft already exists — send the panel fields below instead and the method follows.
title string Participant-facing study title shown to panelists on the provider, 1–255 characters. Required before launch.
description string Participant-facing study description shown to panelists on the provider. Required before launch.
target_participants integer How many participants to recruit, at least 1. Required before launch.
translated_title string Participant-facing title in the study's target language, for a study fielded in a language other than its source. Omit to leave unchanged — the translation pipeline fills it from `title` when empty.
translated_description string Participant-facing description in the study's target language. Omit to leave unchanged — the translation pipeline fills it from `description` when empty.
provider string Panel provider to field with: `PROLIFIC`, `USERINTERVIEWS`, or `RESPONDENT`. Defaults to `PROLIFIC` on a new draft; on an existing unlaunched draft, omitting it keeps the current provider.
block_previous_participants boolean Exclude people already recruited into a previously-launched study in the same project, through the same provider. Omit to leave unchanged; a new draft excludes them where the provider supports it. Forced to `false` on providers that cannot exclude previous participants.
screen_out_limit_usd string Maximum total USD to spend on participants who screen out, as a decimal string of at least `1.00`. The study pauses automatically once screen-out spend reaches it. Omit to leave unchanged; a new draft defaults to 200. Ignored on providers that do not charge for screen-outs.
payout_bonus_usd string Extra USD per participant on top of the provider-calculated base reward, as a non-negative decimal string with at most two decimal places. This is an absolute amount over the base, so sending the same value twice is idempotent. Send `"0"` to return to the base reward.
{
  "title": "20-minute chat about your grocery shopping",
  "description": "Tell us how you plan and shop for weekly groceries. Runs in your browser, no camera needed.",
  "target_participants": 120,
  "provider": "PROLIFIC",
  "screen_out_limit_usd": "200.00",
  "payout_bonus_usd": "0.50"
}
Response

The updated recruitment singleton, in the same shape the read endpoint returns, with the cost estimate recomputed.

FieldTypeDescription
dataobjectThe recruitment singleton — see the read endpoint for the full field list.
{
  "data": {
    "study_id": "0d3c9c7a-4b21-4f6e-9a03-6bb4f1c9a7d2",
    "method": "outset",
    "study_active": false,
    "panel": {
      "id": "b71f2e58-2c9d-4a15-8f30-1de6c4a90b77",
      "launch_state": "DRAFT",
      "is_active": false,
      "provider": "PROLIFIC",
      "provider_recruitment_id": null,
      "title": "20-minute chat about your grocery shopping",
      "target_participants": 120,
      "reward_usd": "4.50",
      "total_cost_usd": "702.00",
      "screen_out_limit_usd": "200.00",
      "block_previous_participants": true
    },
    "self_recruit": null
  }
}
Errors
StatusCodeWhen
409conflictThis study's recruitment is already live on the provider — the guard is the live recruitment, not the study's published state. A live recruitment can only be expanded, through the increase endpoint.
403feature_not_enabled`provider: "RESPONDENT"` on an organization that is not enabled for Respondent recruitment.

title and description go straight onto the provider's panel listing, verbatim — they are participant-facing copy, not internal labels.

List available audience filters #
GET /v2/recruitment-filters/ proposed
Scope studies:read Credentials: user-delegated, partner

List the audience filters a panel provider supports — age, country, employment status, approval rate, and so on. Call this before selecting filters on a study, to discover valid filter ids and option keys. The catalog is a property of the provider, not of any one study.

Query parameters
NameTypeDescription
provider string · default PROLIFIC Panel provider whose catalog to read: `PROLIFIC`, `USERINTERVIEWS`, or `RESPONDENT`.
Response

The provider's full filter catalog. This is not a paginated collection — the catalog is returned whole.

FieldTypeDescription
data.providerstringProvider the catalog belongs to.
data.filters[].idstringFilter id to send when selecting this filter on a study.
data.filters[].namestringHuman-readable filter name.
data.filters[].descriptionstring, nullableLonger explanation of what the filter matches, when the provider supplies one.
data.filters[].questionstring, nullableThe question panelists answered to populate this filter, when the provider supplies it.
data.filters[].categorystring, nullableGrouping the provider files this filter under, useful for rendering a picker.
data.filters[].typestring`select` (pick from `options`) or `range` (supply `min` / `max`).
data.filters[].optionsarray of objectsSelect filters only: the option keys and labels that may be selected.
data.filters[].minstring, nullableRange filters only: lowest value the provider accepts — a number, or an ISO 8601 date for date ranges.
data.filters[].maxstring, nullableRange filters only: highest value the provider accepts.
{
  "data": {
    "provider": "PROLIFIC",
    "filters": [
      {
        "id": "age",
        "name": "Age",
        "category": "Demographics",
        "type": "range",
        "min": "18",
        "max": "100"
      },
      {
        "id": "country_of_residence",
        "name": "Country of residence",
        "category": "Demographics",
        "type": "select",
        "options": [
          {
            "slug": "GB",
            "label": "United Kingdom"
          },
          {
            "slug": "IE",
            "label": "Ireland"
          }
        ]
      }
    ]
  }
}
Select audience filters #
PUT /v2/studies/{study_id}/recruitment/filters/ proposed
Scope studies:write Credentials: user-delegated, partner

Replace the audience filters on a study's panel recruitment draft. This is a full replacement — send the complete set you want, or an empty list to clear all filters. Every filter is validated against the provider's catalog, and the reward estimate is recomputed because narrowing the audience can raise the provider's suggested minimum wage.

Path parameters
NameTypeDescription
study_idUUIDStudy whose recruitment filters to replace.
Request body

The complete filter set. Each entry carries a filter `id` from the catalog plus either `selected_options` (select filters) or `min` / `max` (range filters).

FieldTypeDescription
filters required array of objects Filters to apply. Pass `[]` to clear every filter.
filters[].id required string Filter id from the provider's catalog.
filters[].selected_options array of strings Select filters only: the option keys to accept. Omit for range filters.
filters[].min string Range filters only: lower bound, as a number or an ISO 8601 date string. Omit for select filters.
filters[].max string Range filters only: upper bound, as a number or an ISO 8601 date string. Omit for select filters.
block_previous_participants boolean Exclude people already recruited into a previously-launched study in the same project. Omit to leave the current setting unchanged.
{
  "filters": [
    {
      "id": "age",
      "min": "25",
      "max": "54"
    },
    {
      "id": "country_of_residence",
      "selected_options": [
        "GB",
        "IE"
      ]
    }
  ],
  "block_previous_participants": true
}
Response

The updated recruitment singleton, with the stored filters and the recomputed reward.

FieldTypeDescription
dataobjectThe recruitment singleton — see the read endpoint for the full field list.
Errors
StatusCodeWhen
400validation_errorA filter id, option key, or range bound is not one the provider accepts.
409conflictThe recruitment is already live on the provider — most providers do not allow the audience to be changed after launch. Unpublishing the study does not lift this; the recruitment itself has to be closed.
Estimate reachable audience #
POST /v2/recruitment-audience-estimates/ proposed
Scope studies:read Credentials: user-delegated, partner

Ask a provider how many panelists match a filter set, before committing to it on a study. Use it to check that criteria are not so narrow that recruitment would stall. Nothing is stored, and this does not need a study — pass the filters you are considering.

Request body

The provider and filter set to size.

FieldTypeDescription
provider string Panel provider to ask: `PROLIFIC`, `USERINTERVIEWS`, or `RESPONDENT`. Defaults to `PROLIFIC`.
filters array of objects Filters to size, in the same shape the filter-selection endpoint accepts. Omit or pass `[]` for the provider's whole panel.
{
  "provider": "PROLIFIC",
  "filters": [
    {
      "id": "age",
      "min": "25",
      "max": "54"
    },
    {
      "id": "country_of_residence",
      "selected_options": [
        "GB"
      ]
    }
  ]
}
Response

The provider's eligibility count for the supplied filters.

FieldTypeDescription
data.providerstringProvider the estimate came from.
data.participant_countintegerHow many panelists the provider reports as matching the filter set. An indicative figure from the provider, not a reservation.
{
  "data": {
    "provider": "PROLIFIC",
    "participant_count": 18420
  }
}
Errors
StatusCodeWhen
400validation_errorA filter id, option key, or range bound is not one the provider accepts.

This is a live call to the provider, so it is slower than a typical read and can fail if the provider is unavailable.

Estimate recruitment cost #
POST /v2/studies/{study_id}/recruitment/cost-estimates/ proposed
Scope studies:read Credentials: user-delegated, partner

Price a study's recruitment draft from fresh data — the same arithmetic the launch runs before charging. Pass target_participants for a what-if estimate at a different participant count; nothing is persisted either way. This is a POST because it takes a body and calls the provider, not because it changes anything.

Path parameters
NameTypeDescription
study_idUUIDStudy whose recruitment draft to price.
Request body

Optional overrides for the estimate.

FieldTypeDescription
target_participants integer What-if participant count, at least 1. Defaults to the draft's configured count.
{
  "target_participants": 200
}
Response

The recomputed cost breakdown. Quote `expected_total_cost_usd` — it is the all-in figure, and it is what the product's Recruit page shows as "Total cost".

FieldTypeDescription
data.study_idUUIDStudy the estimate is for.
data.providerstringPanel provider the estimate was priced against.
data.target_participantsintegerParticipant count the estimate was computed at.
data.estimated_duration_secondsintegerEstimated interview length used to price the reward, in seconds.
data.reward_per_assignment_usdstring, nullablePer-participant reward in USD, as a decimal string.
data.total_cost_usdstring, nullableUp-front charge at launch, in USD. Excludes screen-out fees.
data.whitelabel_cost_usdstring, nullablePortion of the cost attributable to whitelabeled fielding, when it applies.
data.expected_total_cost_usdstring, nullableAll-in expected spend: the up-front charge plus projected screen-out fees. The number to quote.
data.charges_for_screenoutsbooleanWhether this provider bills for participants who screen out.
data.per_screenout_fee_usdstring, nullableWhat one screened-out participant costs, in USD.
data.expected_screenout_cost_usdstring, nullableProjected total screen-out spend, computed at a conservative screen-out incidence. Screen-out fees are charged as they occur, not up front.
data.screen_out_limit_usdstring, nullableThe study's configured screen-out spending cap. If it is below `expected_screenout_cost_usd`, the launch is blocked until you raise it.
data.screen_out_spend_usdstring, nullableScreen-out spend already charged against that cap.
{
  "data": {
    "study_id": "0d3c9c7a-4b21-4f6e-9a03-6bb4f1c9a7d2",
    "provider": "PROLIFIC",
    "target_participants": 200,
    "estimated_duration_seconds": 1200,
    "reward_per_assignment_usd": "4.50",
    "total_cost_usd": "1170.00",
    "whitelabel_cost_usd": null,
    "expected_total_cost_usd": "1320.00",
    "charges_for_screenouts": true,
    "per_screenout_fee_usd": "0.75",
    "expected_screenout_cost_usd": "150.00",
    "screen_out_limit_usd": "200.00",
    "screen_out_spend_usd": "0.00"
  }
}
Errors
StatusCodeWhen
400validation_errorThe study has no panel recruitment draft to price.

A study that has already paused for reaching its screen-out cap stays blocked from publishing until the cap is raised above screen_out_spend_usd.

Launch recruitment #
POST /v2/studies/{study_id}/recruitment/launch/ proposed
Scope studies:launch Credentials: user-delegated, partner

Activate the study's panel recruitment draft. Outset validates the study, the screener, and your available funds, publishes the study if it is not already live, charges the full up-front amount, and creates the recruitment on the provider. Participants can start arriving within minutes.

Path parameters
NameTypeDescription
study_idUUIDStudy whose recruitment to launch.
Request body

No body is required.

Response

The recruitment singleton after launch, with `panel.launch_state` moved to `LAUNCHING` or `LIVE`.

FieldTypeDescription
dataobjectThe recruitment singleton — see the read endpoint for the full field list.
{
  "data": {
    "study_id": "0d3c9c7a-4b21-4f6e-9a03-6bb4f1c9a7d2",
    "method": "outset",
    "study_active": true,
    "panel": {
      "id": "b71f2e58-2c9d-4a15-8f30-1de6c4a90b77",
      "launch_state": "LAUNCHING",
      "is_active": true,
      "provider": "PROLIFIC",
      "provider_recruitment_id": null,
      "target_participants": 120,
      "total_cost_usd": "702.00"
    }
  }
}
Errors
StatusCodeWhen
400validation_errorThe draft is incomplete (no participant-facing title or description, no participant count), the screener does not validate, or the screen-out cap is below the projected screen-out cost. The error detail names the specific blocker.
402insufficient_credit_balanceThe organization's recruitment wallet or workspace budget cannot cover the up-front charge. The detail carries the required and current balances.
409conflictRecruitment on this study is already active on the provider.

This spends real money. The full up-front amount is charged at launch — there are no partial commits, and the charge is not reversed by closing the study (unfilled slots are refunded on settlement). It also publishes the study if it was not already published, so the guide goes live at the same moment. Re-launching a recruitment left in FAILED state retries the provider call without charging again.

Expand a live recruitment #
POST /v2/studies/{study_id}/recruitment/increase/ proposed
Scope studies:launch Credentials: user-delegated, partner

Expand a recruitment that is already live: buy more participants, raise the per-participant reward to speed up fielding, or both in one call. At least one field is required. Both changes cost money and are charged when applied.

Path parameters
NameTypeDescription
study_idUUIDStudy whose live recruitment to expand.
Request body

The increases to apply. Send at least one field.

FieldTypeDescription
target_participants integer New target participant count. Must be higher than the current count; the additional cost is charged when the increase is applied.
payout_bonus_usd string Additional per-participant reward in USD, as a decimal string. This is an increment on top of the current reward, so sending the same value twice raises it twice. It applies only to the remaining unfilled slots.
{
  "target_participants": 180,
  "payout_bonus_usd": "0.50"
}
Response

The recruitment singleton with the new participant count and reward.

FieldTypeDescription
dataobjectThe recruitment singleton — see the read endpoint for the full field list.
Errors
StatusCodeWhen
400validation_errorThe new participant count is not higher than the current one, or the provider does not allow reward edits at this stage of the recruitment. Check `provider_capabilities` before offering either control.
402insufficient_credit_balanceThe wallet or workspace budget cannot cover the additional cost.
409conflictThe study has no active recruitment to expand.

Both operations charge immediately, and neither can be undone — a reward increase in particular is a one-way ratchet on the remaining slots. Note the difference from configuring a draft: there, payout_bonus_usd is an absolute amount over the base reward; here it is an increment.

Set recruitment quotas #
PUT /v2/studies/{study_id}/recruitment/quotas/ proposed
Scope studies:write Credentials: user-delegated, partner

Set quota targets on the study's screener so the completed sample matches a shape you specify — a 50/50 gender split, 25% per age band, and so on. Quotas are expressed as per-option shares of the screener questions you enable them on, and this is a full replacement of the quota configuration.

Path parameters
NameTypeDescription
study_idUUIDStudy whose screener quotas to set. The study must already have a screener with questions and options.
Request body

The quota configuration to apply.

FieldTypeDescription
interaction_mode required string How quotas combine across questions: `INDEPENDENT` (each option capped on its own — the common case) or `DEPENDENT` (caps apply to combinations of options across questions, e.g. "women aged 18–25").
questions required array of objects Per-question quota configuration. Include every screener question that should carry quotas.
questions[].screener_question_id required UUID Screener question the quota applies to.
questions[].quota_enabled boolean Whether quotas apply to this question. When `false`, every option on it has its share cleared, whatever `options` contains.
questions[].options required array of objects Target share per option. List every option on the question; shares should sum to no more than 1.0.
questions[].options[].option_id required UUID Screener question option this share applies to.
questions[].options[].quota_percentage number, nullable Share of participants targeted for this option, as a fraction between 0.0 and 1.0 — pass 0.5 on each of two options for an even split. Must be `null` when `is_other_category` is true.
questions[].options[].is_other_category boolean Marks this option as the remainder bucket that catches participants who do not fit the explicit shares. At most one option per question.
flexible_fill boolean When true, a participant is kept as long as one of their matching quota buckets still needs filling and none is at its overflow cap, instead of being disqualified as soon as any matching bucket is full. Omit to leave the current setting unchanged.
{
  "interaction_mode": "INDEPENDENT",
  "flexible_fill": false,
  "questions": [
    {
      "screener_question_id": "3f8b1a92-7c04-4d6e-b1a8-9e2f5c73d410",
      "quota_enabled": true,
      "options": [
        {
          "option_id": "a1c47f60-8d2b-4e19-9f03-5b6e2c84a771",
          "quota_percentage": 0.5
        },
        {
          "option_id": "c93d2e18-6f45-4a70-8b21-7d0c9a54e632",
          "quota_percentage": 0.5
        }
      ]
    }
  ]
}
Response

Confirmation of the applied configuration.

FieldTypeDescription
data.interaction_modestringThe quota mode now in force.
data.questions_configuredintegerHow many screener questions the request configured.
data.flexible_fillbooleanThe study's flexible-fill setting after the change.
{
  "data": {
    "interaction_mode": "INDEPENDENT",
    "questions_configured": 1,
    "flexible_fill": false
  }
}
Errors
StatusCodeWhen
400validation_errorThe study has no screener yet, or a question or option id does not belong to this study's screener.

Quotas govern who is admitted, so a tight configuration can slow fielding or leave a recruitment unable to fill. Changing them invalidates Outset's cached per-quota counts, which regenerate on demand.

Remove recruitment quotas #
DELETE /v2/studies/{study_id}/recruitment/quotas/ proposed
Scope studies:write Credentials: user-delegated, partner

Clear all quota configuration from the study's screener: quotas are turned off on every screener question and every option's target share is cleared. Participants are then admitted purely on screener qualification.

Path parameters
NameTypeDescription
study_idUUIDStudy whose screener quotas to clear.
Response

204 — empty body. Quotas are off on every screener question and every target share is cleared; read the recruitment configuration back to confirm.

Set the invite email template #
PUT /v2/studies/{study_id}/recruitment/invite-email-template/ proposed
Scope studies:write Credentials: user-delegated, partner

Set the copy of the initial invitation email sent to a self-recruited email audience. Saved per study, so editing one study never changes another. This only stores copy — it sends nothing.

Path parameters
NameTypeDescription
study_idUUIDStudy whose invite email copy to set.
Request body

The invite copy. Only the `{name}`, `{company}`, and `{study_name}` merge tags may be used.

FieldTypeDescription
subject string Email subject line. Required for a self-recruit study; ignored for a panel-recruited diary session, whose message renders no email chrome.
body required string Email body — or, for a panel-recruited diary session, the whole message. A diary-session body may not use `{name}`, because a panel participant has no name available at send time.
cta string Label of the button that opens the study. Required for a self-recruit study; ignored for a panel-recruited diary session.
greeting string Greeting line, e.g. `Hello {name},`. Send an empty string to omit the line entirely.
signoff string Sign-off line, e.g. `Thanks so much,`. Send an empty string to omit the line entirely.
researcher_name string Researcher name shown in the sign-off. Omit to leave the study's stored contact unchanged; send an empty string to clear it.
researcher_email string Reply-to address so participants can reply to a person. Omit to leave the study's stored contact unchanged; send an empty string to clear it.
{
  "subject": "Can we borrow 20 minutes of your time?",
  "greeting": "Hi {name},",
  "body": "We're improving how {company} plans weekly grocery shopping and would love your take. The conversation takes about 20 minutes and runs in your browser.",
  "cta": "Start the interview",
  "signoff": "Thanks so much,",
  "researcher_name": "Priya Raman",
  "researcher_email": "priya@example.com"
}
Response

The resolved template after the change.

FieldTypeDescription
data.fieldsobjectThe resolved copy an editor would pre-fill — the study's own copy where set, Outset's standard template otherwise.
data.sourcestringWhich tier supplied the resolved copy: this study's saved copy, or the standard template.
data.defaultsobjectOutset's standard template copy, so a client can offer a reset.
Errors
StatusCodeWhen
403feature_not_enabledThe organization does not have email-invite recruitment enabled.
400validation_errorThe copy uses a merge tag other than `{name}` / `{company}` / `{study_name}`, or uses `{name}` on a panel-recruited diary session.

The 24-hour follow-up reminder is never customizable — this endpoint only sets the initial email.

Add an invite audience #
POST /v2/studies/{study_id}/recruitment/invite-audiences/ proposed
Scope studies:write Credentials: user-delegated, partner

Add a list of email recipients to a self-recruited study, either as structured entries or as the raw text of a CSV. Send exactly one of recipients or csv_content. Recipients are de-duplicated within the request and against the study's existing audience, and suppression-filtered, so calling this repeatedly adds people without creating duplicate invites.

Path parameters
NameTypeDescription
study_idUUIDStudy to add the audience to.
Request body

The audience to add. `recipients` validates strictly — one bad address rejects the whole request; `csv_content` is lenient and reports skipped rows in the response counts.

FieldTypeDescription
recipients array of objects Structured recipient list. Use instead of `csv_content`.
recipients[].email required string Recipient email address. Every address must be valid.
recipients[].name string Recipient display name, used by the `{name}` merge tag.
recipients[].metadata array of objects Extra per-recipient columns as `key` / `value` pairs. Each value rides onto the participant and becomes filterable in reports. `email` and `name` are reserved keys, and at most 20 distinct column names may appear across all recipients.
csv_content string Raw text of an audience CSV, under 5 MB. Use instead of `recipients`. Columns are auto-detected; invalid rows are skipped and counted rather than failing the request.
email_column string With `csv_content`: header name of the email column, when auto-detection would get it wrong.
name_column string With `csv_content`: header name of the name column.
metadata_columns array of strings With `csv_content`: header names of extra columns to ingest as per-recipient metadata, up to 20.
{
  "recipients": [
    {
      "email": "dana@example.com",
      "name": "Dana Whitfield",
      "metadata": [
        {
          "key": "plan",
          "value": "enterprise"
        }
      ]
    },
    {
      "email": "sam@example.com",
      "name": "Sam Oyelaran"
    }
  ]
}
Response

The audience that was created, plus the study's updated recipient counts.

FieldTypeDescription
data.audience_upload.upload_idUUIDIdentifier of the audience added by this request.
data.audience_upload.original_filenamestringFilename recorded for this audience, for display alongside file-uploaded audiences.
data.audience_upload.row_countintegerRows received in the request.
data.audience_upload.valid_countintegerRows that produced a usable recipient.
data.audience_upload.duplicate_countintegerRows dropped because the address was already on this study's audience or appeared twice in the request.
data.audience_upload.statusstringProcessing state of this audience.
data.audience_stats.recipients_readyintegerValid recipients on the study that have not been invited yet.
data.audience_stats.new_invitesintegerRecipients this request added that have not been invited yet.
data.audience_stats.invitations_sentintegerInvites already sent for this study.
data.audience_stats.target_participantsintegerHow many completed interviews the study is aiming for.
{
  "data": {
    "audience_upload": {
      "upload_id": "8e40a1b6-5d72-4c98-b2e1-73f0c9a4d215",
      "original_filename": "api-recipients.csv",
      "row_count": 2,
      "valid_count": 2,
      "duplicate_count": 0,
      "status": "COMPLETED"
    },
    "audience_stats": {
      "recipients_ready": 2,
      "new_invites": 2,
      "invitations_sent": 0,
      "target_participants": 40
    }
  }
}
Errors
StatusCodeWhen
403feature_not_enabledThe organization does not have email-invite recruitment enabled.
400validation_errorNeither or both of `recipients` and `csv_content` were sent, an address in `recipients` is invalid, or the CSV cannot be parsed.

This is additive and sends nothing. Releasing the audience so invites actually go out is a separate step, and each call creates its own audience entry even though the addresses themselves de-duplicate.

Generate synthetic interviews #
POST /v2/studies/{study_id}/synthetic-responses/ proposed
Scope studies:write Credentials: user-delegated, partner

Smoke-test a study before spending money on real participants: an AI simulates the audience you describe and runs it through the study's guide, producing synthetic interviews. Returns 202 with a job to poll; the interviews land over the following few minutes and appear in interview lists under the SYNTH interview type.

Path parameters
NameTypeDescription
study_idUUIDStudy to generate synthetic interviews for.
Request body

The audience to simulate and how many interviews to run.

FieldTypeDescription
audience_description required string Free-text description of the audience to simulate, e.g. "busy parents who grocery shop online weekly".
count required integer How many synthetic interviews to generate, between 1 and 50. Each one runs the full study guide.
{
  "audience_description": "Busy parents in the UK who order groceries online at least weekly",
  "count": 10
}
Response

`202 Accepted` with the job to poll.

FieldTypeDescription
data.job_idUUIDJob identifier — poll the job endpoint below for progress.
data.statusstringJob state, starting at `QUEUED`.
data.study_idUUIDStudy the interviews are being generated for.
data.countintegerHow many synthetic interviews were requested.
{
  "data": {
    "job_id": "cf1d7b04-9a63-4e28-8d51-2f7e60c1a983",
    "status": "QUEUED",
    "study_id": "0d3c9c7a-4b21-4f6e-9a03-6bb4f1c9a7d2",
    "count": 10
  }
}
Errors
StatusCodeWhen
400validation_errorThe study is a usability study (screen-share or mobile UX). Those lead with a task an AI participant cannot perform, so synthetic interviews would come back empty.

Synthetic interviews are AI-generated and are not evidence about real people — use them to check that a guide flows, not to draw conclusions. They are excluded from interview lists by default; request the SYNTH interview type to see them.

Check a synthetic interview job #
GET /v2/studies/{study_id}/synthetic-responses/{job_id}/ proposed
Scope studies:read Credentials: user-delegated, partner

Poll a synthetic interview generation job. Terminal states are COMPLETED and FAILED; once complete, fetch the interviews themselves from the interview list endpoints with the SYNTH interview type.

Path parameters
NameTypeDescription
study_idUUIDStudy the job belongs to.
job_idUUIDJob returned when the generation was requested.
Response

Current job state.

FieldTypeDescription
data.job_idUUIDJob identifier.
data.statusstring`QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`.
data.countintegerHow many synthetic interviews were requested.
data.completed_countintegerHow many have finished so far.
data.errorstring, nullableWhy the job failed, when `status` is `FAILED`.
Request managed recruitment #
POST /v2/custom-recruitment-requests/ proposed
Scope studies:write Credentials: user-delegated, partner

Ask Outset's recruitment team to source participants the self-serve panels cannot reach — niche B2B roles, specific product users, hard-to-find markets. This files a request that a person picks up; it recruits nobody and launches nothing.

Request body

What you need recruited. All four fields are required and must be non-blank.

FieldTypeDescription
interview_length_minutes required string How long each interview should run, in minutes. Up to 255 characters.
participant_count required string How many qualifying participants are needed. Up to 255 characters.
participant_location required string Where participants should be located — city, state, country, or region. For several markets, use the form "Location: number of participants". Up to 255 characters.
screening_criteria required string Required participant criteria — B2B or B2C, job title, industry, company size, or the specific products they use. Up to 5000 characters.
{
  "interview_length_minutes": "30",
  "participant_count": "12",
  "participant_location": "United Kingdom: 8, Ireland: 4",
  "screening_criteria": "B2B. Procurement leads at grocery retailers with 50+ stores who have changed supplier software in the last 18 months."
}
Response

Confirmation that the request was accepted for delivery to the recruitment team.

FieldTypeDescription
data.request_idUUIDReference for this request, worth quoting in any follow-up.
data.statusstring`submitted` — the request has been accepted for delivery.
{
  "data": {
    "request_id": "e2b5d3a7-01c4-4f92-a6d8-58c0f9e14b36",
    "status": "submitted"
  }
}
Errors
StatusCodeWhen
400validation_errorAny of the four fields is blank or whitespace-only.

Managed recruitment is priced and scheduled by the Outset team, not by this API — the request opens a conversation, it does not commit either side to anything.

Chapter 12

Interviews & Transcripts #

An interview is one participant's session through a study: the transcript, the screener answers they gave to qualify, recording metadata, and any observations the vision pipeline derived from their recordings. These are the rawest participant data the API exposes, so a PII gate sits under every endpoint in this chapter. A completed interview on a PII-enabled study is scanned before it is readable — while detection is running (or if it failed) the whole interview reads as 404, and a live interview on such a study is withheld until it completes. Once scanning finishes, flagged content is handled per message rather than per interview: a message with an unresolved flag comes back as "[PII Under Review]" with its structured answer values and media URLs withheld, and a message whose flag was accepted and redacted comes back as "[PII Redacted]" with is_redacted: true. Redaction is permanent — the original text is destroyed in place, so no surface can recover it. Reads need analytics:read; transcript corrections and the PII flag lifecycle need analytics:write.

List interviews in a study #
GET /v2/studies/{study_id}/interviews/ existing
Scope analytics:read Credentials: user-delegated, partner

Lists the interviews collected for one study, newest first. Defaults match the researcher UI — real participant interviews only, archived excluded — so opt in explicitly to synthetic, test, and imported rows.

Path parameters
NameTypeDescription
study_iduuidIdentifier of the study whose interviews to list.
Query parameters
NameTypeDescription
interview_types string (repeatable) · default USER Interview types to include: USER (human participant), SYNTH (AI-generated), TEST (internal test), IMPRT (transcript imported from elsewhere).
only_screened_in boolean · default False Return only interviews that passed the screener and count as valid completes — excludes in-progress, screened-out, fraud, and low-quality sessions.
only_screened_out boolean · default False Return only interviews the screener rejected; cannot be combined with only_screened_in.
include_archived boolean · default False Include interviews the researcher has archived.
completed_only boolean · default False Return only interviews that reached the end of the study (completed_at is set).
since datetime Return only interviews created at or after this ISO 8601 UTC timestamp.
page_size integer · default 50 Number of interviews per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of interview rows, ordered newest-first.

FieldTypeDescription
data[].iduuidInterview identifier, used as interview_id everywhere else in this chapter.
data[].namestring, nullableDisplay name of the interview, when one was assigned.
data[].typestringOne of USER, SYNTH, TEST, IMPRT.
data[].started_atdatetime, nullableWhen the interview was created, i.e. when the participant entered.
data[].completed_atdatetime, nullableWhen the participant reached the end of the study; null if they never finished.
data[].is_activebooleanWhether the session is still live.
data[].progress_percentagefloatShare of the study the participant got through, as a fraction between 0 and 1 (0.42 means 42%).
data[].screened_outboolean, nullableWhether the screener rejected this participant; null before the screener resolves.
data[].is_fraudbooleanWhether fraud detection rejected this interview.
data[].is_low_qualitybooleanWhether quality checks marked the responses too poor to count.
data[].language_codestring, nullableLanguage the interview was conducted in, as an IETF code such as en or pt-BR.
data[].fraud_scorefloat, nullableFraud-model score for the session; null when no score was produced.
data[].total_engagement_time_secondsfloat, nullableTotal seconds the participant was engaged across all answers — thinking, speaking, and typing time, not media length.
data[].archivedbooleanWhether the researcher archived this interview.
data[].statusstringHuman-readable rollup, first match wins: Fraud detected, Screened out, Over quota, Incomplete, Completed, Active.
data[].urlstringDeep link to this interview in the Outset app, of the form /project/{project_id}/interview/{interview_id}/read — it carries the project the interview sits in, not the study it belongs to.
{
  "data": [
    {
      "id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
      "name": "Participant 214",
      "type": "USER",
      "started_at": "2026-07-28T14:02:11Z",
      "completed_at": "2026-07-28T14:23:47Z",
      "is_active": false,
      "progress_percentage": 1.0,
      "screened_out": false,
      "is_fraud": false,
      "is_low_quality": false,
      "language_code": "en",
      "fraud_score": 0.04,
      "total_engagement_time_seconds": 1183.5,
      "archived": false,
      "status": "Completed",
      "url": "https://app.outset.ai/project/3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1/interview/0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774/read"
    }
  ],
  "next_cursor": "cD0yMDI2LTA3LTI4VDE0OjAyOjExWg",
  "has_more": true
}
Errors
StatusCodeWhen
400validation_errorAn unknown interview_types code, only_screened_in combined with only_screened_out, an unparseable since, or a boolean parameter that is not true/false/1/0.
404not_foundThe study is outside the credential's organization or granted workspaces.

Interviews whose PII scan has not finished are omitted from this list entirely, so a study can report fewer interviews here than its recruitment numbers suggest for a few minutes after a wave completes. Interviews awaiting PII review are listed — their masking happens when you fetch the transcript.

List interviews in a project #
GET /v2/projects/{project_id}/interviews/ existing
Scope analytics:read Credentials: user-delegated, partner

Lists interviews across every study in a project. Identical filters, ordering, pagination, and row shape to the study-scoped list.

Path parameters
NameTypeDescription
project_iduuidIdentifier of the project whose interviews to list.
Query parameters
NameTypeDescription
interview_types string (repeatable) · default USER Interview types to include: USER, SYNTH, TEST, IMPRT.
only_screened_in boolean · default False Return only interviews that passed the screener and count as valid completes.
only_screened_out boolean · default False Return only interviews the screener rejected; cannot be combined with only_screened_in.
include_archived boolean · default False Include interviews the researcher has archived.
completed_only boolean · default False Return only interviews that reached the end of their study.
since datetime Return only interviews created at or after this ISO 8601 UTC timestamp.
page_size integer · default 50 Number of interviews per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of interview rows in the same shape as the study-scoped list.

FieldTypeDescription
data[].iduuidInterview identifier, used as interview_id everywhere else in this chapter.
data[].namestring, nullableDisplay name of the interview, when one was assigned.
data[].typestringOne of USER, SYNTH, TEST, IMPRT.
data[].started_atdatetime, nullableWhen the interview was created, i.e. when the participant entered.
data[].completed_atdatetime, nullableWhen the participant reached the end of the study; null if they never finished.
data[].is_activebooleanWhether the session is still live.
data[].progress_percentagefloatShare of the study the participant got through, as a fraction between 0 and 1 (0.42 means 42%).
data[].screened_outboolean, nullableWhether the screener rejected this participant; null before the screener resolves.
data[].is_fraudbooleanWhether fraud detection rejected this interview.
data[].is_low_qualitybooleanWhether quality checks marked the responses too poor to count.
data[].language_codestring, nullableLanguage the interview was conducted in, as an IETF code such as en or pt-BR.
data[].fraud_scorefloat, nullableFraud-model score for the session; null when no score was produced.
data[].total_engagement_time_secondsfloat, nullableTotal seconds the participant was engaged across all answers — thinking, speaking, and typing time, not media length.
data[].archivedbooleanWhether the researcher archived this interview.
data[].statusstringHuman-readable rollup, first match wins: Fraud detected, Screened out, Over quota, Incomplete, Completed, Active.
data[].urlstringDeep link to this interview in the Outset app, of the form /project/{project_id}/interview/{interview_id}/read — it carries the project the interview sits in, not the study it belongs to.
{
  "data": [
    {
      "id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
      "name": "Participant 214",
      "type": "USER",
      "started_at": "2026-07-28T14:02:11Z",
      "completed_at": "2026-07-28T14:23:47Z",
      "is_active": false,
      "progress_percentage": 1.0,
      "screened_out": false,
      "is_fraud": false,
      "is_low_quality": false,
      "language_code": "en",
      "fraud_score": 0.04,
      "total_engagement_time_seconds": 1183.5,
      "archived": false,
      "status": "Completed",
      "url": "https://app.outset.ai/project/3f7c1a90-52d8-4c11-9a6e-8b0d4f2e77a1/interview/0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774/read"
    }
  ],
  "next_cursor": "cD0yMDI2LTA3LTI4VDE0OjAyOjExWg",
  "has_more": true
}
Errors
StatusCodeWhen
404not_foundThe project is outside the credential's organization or granted workspaces.

Rows carry no study identifier of their own, and url is project-scoped rather than study-scoped, so it cannot attribute an interview to its study either — pair this with the study-scoped list when you need that attribution.

Get an interview transcript #
GET /v2/interviews/{interview_id}/ existing
Scope analytics:read Credentials: user-delegated, partner

Returns one interview with its full ordered transcript, the screener answers the participant gave, and any disqualification causes. Closed-ended answers carry structured values on the message that answered them, so you do not have to parse text to get selected options, numeric ratings, or matrix picks.

Path parameters
NameTypeDescription
interview_iduuidIdentifier of the interview to fetch.
Query parameters
NameTypeDescription
include_recordings boolean · default False Include short-lived presigned URLs for message recordings and participant uploads; off by default because each file costs a signing round-trip.
Response

Single-object envelope carrying the interview row, its messages, and its screener answers. The example below shows one array element of each; interview object fields: see List a study's interviews.

FieldTypeDescription
data.interviewobjectInterview row in the same shape as the list endpoints.
data.messages[].iduuidMessage identifier, used as message_id when correcting or flagging it.
data.messages[].orderintegerPosition of the message in the transcript.
data.messages[].rolestringWho produced the message: PARTICIPANT, INTERVIEWER, or SYSTEM.
data.messages[].contentstringMessage text; reads "[PII Under Review]" while a flag is unresolved and "[PII Redacted]" once redaction has run.
data.messages[].created_atstringISO 8601 UTC timestamp of when the message was recorded.
data.messages[].is_endbooleanWhether this message closed the interview.
data.messages[].question_iduuid, nullableStudy question this message answers or asks; null for messages outside the guide.
data.messages[].engagement_time_secondsfloat, nullableSeconds the participant was connected before sending this message — thinking, speaking, and typing time.
data.messages[].is_redactedbooleanWhether the content was permanently redacted after a reviewer accepted a PII flag.
data.messages[].recordingobject, nullableRecording metadata as {duration_seconds, media_type, url}; url appears only with include_recordings=true and only when the media is cleared of PII.
data.messages[].participant_uploads[]arrayFiles the participant uploaded on this message as {id, content_type, is_redacted, url}; url is never present for a redacted upload.
data.messages[].participant_skippedbooleanWhether the participant deliberately skipped an optional question.
data.messages[].selected_options[]arrayOptions the participant picked on a closed-ended question, in pick order (rank order for stack-rank questions), each as {id, text, custom_text}.
data.messages[].matrix_selections[]array, nullablePer-row picks for a matrix answer as {row_question_id, option_id}; null for non-matrix messages.
data.messages[].numeric_valuefloat, nullableNumeric answer to a rating or number question; null when the message is not a plain numeric answer.
data.messages[].task_statusstring, nullableTask outcome (COMPLETE or INCOMPLETE) for task questions; null for other messages.
data.messages[].task_duration_secondsinteger, nullableSeconds the participant spent on the task; null for other messages.
data.screener_answers[].question_iduuidScreener question that was answered.
data.screener_answers[].question_textstringText of the screener question as the participant saw it.
data.screener_answers[].selected_options[]arrayOptions picked, each as {id, text}; options the researcher has since deleted still appear here so answer history stays readable.
data.screener_answers[].textstringFree-text answer, empty when the question was closed-ended.
data.screener_answers[].participant_skippedbooleanWhether the participant skipped this screener question.
data.screener_answers[].image_uploadobject, nullableImage the participant uploaded to an image-upload screener question as {id, content_type, status, meets_criterion, analysis, is_redacted, presigned_image_url}; null until the vision pass completes.
data.disqualification_causesobject, nullableFree-form record of why the participant was disqualified, passed through as stored.
{
  "data": {
    "interview": {
      "id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
      "status": "Completed",
      "…": "remaining interview row fields"
    },
    "messages": [
      {
        "id": "c40b25de-1a97-4f68-8d33-9b7e5c10a2f6",
        "order": 5,
        "role": "PARTICIPANT",
        "content": "About twice a month, usually for bulk items.",
        "created_at": "2026-07-28T14:06:41Z",
        "question_id": "d21a8f36-77b4-4c0e-b5a9-3ef1c8d92057",
        "is_redacted": false,
        "recording": null,
        "…": "remaining message fields, null or empty on this message"
      },
      "… one entry per turn, in transcript order"
    ],
    "screener_answers": [
      {
        "question_id": "5f8e3c21-40db-4a76-91c5-6d2b7e0a34c9",
        "question_text": "Which of these do you buy at least monthly?",
        "selected_options": [
          {
            "id": "9c2d61b8-7e04-4f3a-a1d5-08b6c93e457f",
            "text": "Fresh produce"
          }
        ],
        "text": "",
        "participant_skipped": false
      },
      "… one entry per screener question"
    ],
    "disqualification_causes": null
  }
}
Errors
StatusCodeWhen
404not_foundThe interview is outside the credential's reach, its PII scan has not completed, or it is still live on a PII-enabled study — all three answer identically by design.

Presigned media URLs expire within minutes: fetch the bytes immediately rather than storing the URL. The transcript is returned in the language the interview was conducted in; translated transcripts are available through the export surface.

Correct a transcript message #
PATCH /v2/interviews/{interview_id}/messages/{message_id}/ proposed
Scope analytics:write Credentials: user-delegated, partner

Corrects the text of a single transcript message — the surface for fixing transcription errors and typos. The pre-edit text is snapshotted so the correction can be undone, and the message is marked as edited. Participant, interviewer, and system messages can all be corrected.

Path parameters
NameTypeDescription
interview_iduuidIdentifier of the interview the message belongs to.
message_iduuidIdentifier of the message to correct.
Request body

The replacement text. `text` is the only editable field.

FieldTypeDescription
text required string Corrected text, replacing the message's existing text in full.
{
  "text": "About twice a month, usually for bulk items."
}
Response

Single-object envelope carrying the corrected message.

FieldTypeDescription
data.iduuidIdentifier of the message.
data.interview_iduuidInterview the message belongs to.
data.textstringText now stored on the message.
data.editedbooleanWhether the message carries a researcher correction, which the app surfaces to viewers.
{
  "data": {
    "id": "c40b25de-1a97-4f68-8d33-9b7e5c10a2f6",
    "interview_id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
    "text": "About twice a month, usually for bulk items.",
    "edited": true
  }
}
Errors
StatusCodeWhen
400pii_maskedThe message has an unresolved PII flag. Editing it would write through content that is masked from every read surface, so resolve the flag first.
404not_foundThe interview is out of reach, or the message does not belong to that interview.

Sending text identical to what is already stored is a no-op — the message keeps its existing edited state and no new history snapshot is written. A correction invalidates derived artifacts built from the old text (translations of the message and burned captions on its recording), which regenerate asynchronously; a caption or translated transcript fetched immediately after may still show the pre-correction wording.

Revert a corrected message #
POST /v2/interviews/{interview_id}/messages/{message_id}/revert/ proposed
Scope analytics:write Credentials: user-delegated, partner

Restores a corrected message to the text the participant or interviewer originally produced and clears its edited marker.

Path parameters
NameTypeDescription
interview_iduuidIdentifier of the interview the message belongs to.
message_iduuidIdentifier of the message to revert.
Response

Single-object envelope carrying the reverted message, in the same shape as a correction.

FieldTypeDescription
data.iduuidIdentifier of the message.
data.interview_iduuidInterview the message belongs to.
data.textstringText now stored on the message.
data.editedbooleanWhether the message carries a researcher correction, which the app surfaces to viewers.
{
  "data": {
    "id": "c40b25de-1a97-4f68-8d33-9b7e5c10a2f6",
    "interview_id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
    "text": "about twice a month usually for bulk items",
    "edited": false
  }
}
Errors
StatusCodeWhen
400no_previous_versionThe message was never corrected, so there is no original to restore.
400pii_maskedThe message has an unresolved PII flag; reverting would echo back masked content.

Reverting always restores the first pre-edit snapshot, not the previous one — a message corrected several times returns to the text it started with in a single call.

Flag a message as containing PII #
POST /v2/interviews/{interview_id}/messages/{message_id}/pii-flags/ proposed
Scope analytics:write Credentials: user-delegated, partner

Raises a PII flag on a participant message, putting it into review. The flag is created OPEN, and the message is masked as "[PII Under Review]" on every read surface until the flag is reviewed.

Path parameters
NameTypeDescription
interview_iduuidIdentifier of the interview the message belongs to.
message_iduuidIdentifier of the participant message to flag.
Response

Single-object envelope carrying the newly created flag.

FieldTypeDescription
data.iduuidFlag identifier, used to review the flag.
data.interview_iduuidInterview the flagged message belongs to.
data.message_iduuidMessage the flag was raised on.
data.statusstringLifecycle state of the flag; always OPEN on creation.
{
  "data": {
    "id": "7e5a2b90-c34d-4f18-a6b2-19d07c8e5f43",
    "interview_id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
    "message_id": "c40b25de-1a97-4f68-8d33-9b7e5c10a2f6",
    "status": "OPEN"
  }
}
Errors
StatusCodeWhen
400message_not_flaggablePII flagging is not enabled on the study, the message is not a participant message, or the message is already flagged — a message can only carry one flag.
404not_foundThe interview is out of reach, or the message does not belong to that interview.

Flagging takes effect immediately for readers: the message is masked from the moment the flag exists, before anyone reviews it. Keep the returned flag id — it is the only handle to the flag, and it is not carried on interview or message reads. GET /v2/pii-flags/{flag_id}/ reads its state back.

Review a PII flag #
POST /v2/pii-flags/{flag_id}/review/ proposed
Scope analytics:write Credentials: user-delegated, partner

Resolves a PII flag: accept it to redact the flagged content, dismiss it to keep the content as-is, or reopen a dismissed flag for another look. The call records who reviewed the flag and when.

Path parameters
NameTypeDescription
flag_iduuidIdentifier of the PII flag to review.
Request body

The review decision.

FieldTypeDescription
action required string One of accept (redact the flagged content), dismiss (keep it and unmask the message), or reopen (return a dismissed flag to review).
{
  "action": "accept"
}
Response

Single-object envelope carrying the flag's new state.

FieldTypeDescription
data.iduuidIdentifier of the reviewed flag.
data.statusstringNew lifecycle state: OPEN, DISMISSED, ACCEPTED (redaction queued or running), RESOLVED (redaction complete), or FAILED (redaction failed).
{
  "data": {
    "id": "7e5a2b90-c34d-4f18-a6b2-19d07c8e5f43",
    "status": "ACCEPTED"
  }
}
Errors
StatusCodeWhen
400pii_flag_invalid_transitionThe decision is not legal from the flag's current state. An OPEN flag can be accepted or dismissed, a DISMISSED flag can only be reopened, a FAILED flag can only be accepted (retrying redaction), and a RESOLVED flag is terminal. A flag whose category the organization configured as delete-only can never be dismissed.
404not_foundThe flag is outside the credential's organization or granted workspaces.

Accepting is asynchronous and irreversible. The call returns immediately with status ACCEPTED while redaction runs in the background; poll GET /v2/pii-flags/{flag_id}/ to see it settle at RESOLVED or FAILED. On success the message content also reads "[PII Redacted]" with is_redacted: true on the interview transcript. The original text is destroyed — no surface, export, or support path can recover it, so treat accept as a permanent deletion of that content. Dismissing unmasks the message and regenerates the interview's summary. The route is top-level rather than nested under the interview because a flag id is globally unique and resolves its own interview, study, and workspace.

Get a PII flag #
GET /v2/pii-flags/{flag_id}/ proposed
Scope analytics:read Credentials: user-delegated, partner

Reads one flag's current state. Redaction runs in the background after a flag is accepted, so this is the surface that reports whether it finished (RESOLVED) or failed (FAILED).

Path parameters
NameTypeDescription
flag_iduuidIdentifier of the PII flag to read.
Response

Single-object envelope carrying the flag, in the same shape the flag-creation call returns.

FieldTypeDescription
data.iduuidFlag identifier.
data.interview_iduuidInterview the flagged message belongs to.
data.message_iduuidMessage the flag was raised on.
data.statusstringLifecycle state: OPEN, DISMISSED, ACCEPTED (redaction queued or running), RESOLVED (redaction complete), or FAILED (redaction failed).
{
  "data": {
    "id": "7e5a2b90-c34d-4f18-a6b2-19d07c8e5f43",
    "interview_id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
    "message_id": "c40b25de-1a97-4f68-8d33-9b7e5c10a2f6",
    "status": "RESOLVED"
  }
}
Errors
StatusCodeWhen
404not_foundThe flag is outside the credential's organization or granted workspaces.

A FAILED flag leaves the message masked as "[PII Under Review]" — accept it again to retry redaction. This read is the only way to see that state, because a failed redaction never changes the transcript.

List vision observations for an interview #
GET /v2/interviews/{interview_id}/vision-observations/ proposed
Scope analytics:read Credentials: user-delegated, partner

Lists the observations Outset's vision pipeline derived from one interview's screen and camera recordings — moments of success, friction, or unexpected behavior, each anchored to a timestamp window in the recording. Only interviews with a completed vision analysis return rows.

Path parameters
NameTypeDescription
interview_iduuidIdentifier of the interview whose observations to list.
Query parameters
NameTypeDescription
observation_type string Restrict to one kind of observation: success, friction, or unexpected.
page_size integer · default 50 Number of observations per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of observations, ordered by question and then by their position in the recording.

FieldTypeDescription
data[].iduuidObservation identifier.
data[].titlestringShort label for what was observed.
data[].descriptionstringFull description of the observed behavior.
data[].observation_typestringOne of success, friction, or unexpected.
data[].ease_of_useinteger, nullableObserved ease of use on a 1 (very difficult) to 5 (very easy) scale; null when the pipeline did not rate it.
data[].surface_areastringPart of the tested experience the observation is about, as named by the pipeline; empty when unattributed.
data[].question_iduuid, nullableStudy question the participant was working on; null when the observation is not tied to one.
data[].interview_iduuidInterview the observation came from.
data[].start_timestamp_secondsintegerSeconds into the recording where the observed behavior starts.
data[].end_timestamp_secondsintegerSeconds into the recording where the observed behavior ends.
{
  "data": [
    {
      "id": "e8137a45-90cb-4d26-b7f1-2a5c0d6e39b8",
      "title": "Could not find the filter control",
      "description": "The participant scrolled the results list twice looking for a way to narrow by size before opening the sort menu by mistake.",
      "observation_type": "friction",
      "ease_of_use": 2,
      "surface_area": "Search results",
      "question_id": "d21a8f36-77b4-4c0e-b5a9-3ef1c8d92057",
      "interview_id": "0a4f7c62-9d31-4b8e-8a05-2f6d1c93b774",
      "start_timestamp_seconds": 142,
      "end_timestamp_seconds": 171
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Errors
StatusCodeWhen
404not_foundThe interview is out of reach, still live on a PII-enabled study, or has unresolved PII — observations derive from participant recordings, so any unsettled PII state withholds them.

The PII gate here is stricter than the transcript's: an interview merely awaiting PII review returns 404 rather than a partially masked list, because an observation describes recorded behavior that cannot be masked message-by-message. An empty list is normal — it means the interview had no screen or camera recording to analyze, or the analysis found nothing notable.

Chapter 13

Analysis & Insights #

This is where fielded research turns into answers. When a study's interviews complete, Outset analyzes them into a report — an executive topline, a per-question breakdown with categories and quotes, and, depending on what the study captured, emotion, vision, prototype-interaction, concept-testing and participant-upload layers on top. A report is an immutable point-in-time snapshot: every read below is a stable artifact you can cite, cache, and reconcile against, and nothing here mutates analysis output. The one write is a rerun, which regenerates the snapshot from the latest interviews. Crosstabs, emotion aggregates, prototype interaction maps, research-goal conclusions and concept reads are first-class endpoints here, not fields buried inside a page payload.

List a project's reports #
GET /v2/projects/{project_id}/reports/ proposed
Scope analytics:read Credentials: user-delegated, partner

Lists the reports generated for one project, including the automatically maintained insights report every project gets once it has enough completed interviews. This is the usual entry point: resolve a report id here, then read its topline, questions, and answers.

Path parameters
NameTypeDescription
project_iduuidIdentifier of the project.
Query parameters
NameTypeDescription
page_size integer · default 50 Number of reports per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of the project's reports, newest first.

FieldTypeDescription
data[].iduuidReport identifier. It changes every time the report is rerun, because a rerun replaces the snapshot.
data[].namestringDisplay name of the report.
data[].project_iduuidProject the report belongs to.
data[].study_idsarray[uuid]Studies whose interviews this report analyzes; more than one when the report spans a project.
data[].is_insights_reportbooleanWhether this is the project's automatically generated and refreshed insights report.
data[].is_filteredbooleanWhether the report covers a filtered subset of interviews rather than all of them.
data[].created_atdatetimeWhen this snapshot was created.
data[].last_run_atdatetimeWhen the analysis behind this snapshot last finished writing.
data[].latest_runobjectIn-flight or failed run for this report, or null when nothing is running; see the run resource for its fields.
data[].urlurlDeep link to the report in the Outset web app, for a human hand-off.
{
  "data": [
    {
      "id": "9d41c2a7-6b0e-4f38-a1c5-70e2b8d4f911",
      "name": "Spring refresh — packaging concepts",
      "project_id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
      "study_ids": [
        "3a7f5c19-24bd-4e08-9c6a-11d3f0e7b452"
      ],
      "is_insights_report": true,
      "is_filtered": false,
      "created_at": "2026-08-09T04:11:07Z",
      "last_run_at": "2026-08-09T04:38:52Z",
      "latest_run": null,
      "url": "https://app.outset.ai/project/b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77/insights"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Report ids are not stable across runs. A rerun deletes the previous snapshot and writes a new one with a new id, so an integration that stores a report id should re-resolve it from this list after any run completes rather than treating a later 404 as an error.

List all reports #
GET /v2/reports/ proposed
Scope analytics:read Credentials: user-delegated, partner

Lists every report the credential can reach across the organization, newest first. Use it to sweep an estate for freshly completed analysis instead of walking projects one at a time.

Query parameters
NameTypeDescription
workspace_id uuid Return only reports whose project lives in this workspace.
updated_since datetime Return only reports whose analysis last finished at or after this ISO 8601 timestamp.
page_size integer · default 50 Number of reports per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated list of reports in the same row shape as the project-scoped list.

FieldTypeDescription
data[].iduuidReport identifier. It changes every time the report is rerun, because a rerun replaces the snapshot.
data[].namestringDisplay name of the report.
data[].project_iduuidProject the report belongs to.
data[].study_idsarray[uuid]Studies whose interviews this report analyzes; more than one when the report spans a project.
data[].is_insights_reportbooleanWhether this is the project's automatically generated and refreshed insights report.
data[].is_filteredbooleanWhether the report covers a filtered subset of interviews rather than all of them.
data[].created_atdatetimeWhen this snapshot was created.
data[].last_run_atdatetimeWhen the analysis behind this snapshot last finished writing.
data[].latest_runobjectIn-flight or failed run for this report, or null when nothing is running; see the run resource for its fields.
data[].urlurlDeep link to the report in the Outset web app, for a human hand-off.
{
  "data": [
    {
      "id": "9d41c2a7-6b0e-4f38-a1c5-70e2b8d4f911",
      "name": "Spring refresh — packaging concepts",
      "project_id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
      "study_ids": [
        "3a7f5c19-24bd-4e08-9c6a-11d3f0e7b452"
      ],
      "is_insights_report": true,
      "is_filtered": false,
      "created_at": "2026-08-09T04:11:07Z",
      "last_run_at": "2026-08-09T04:38:52Z",
      "latest_run": null,
      "url": "https://app.outset.ai/project/b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77/insights"
    },
    {
      "…": "remaining reports omitted — newest first"
    }
  ],
  "next_cursor": "cD0yMDI2LTA4LTA5VDA0OjM4OjUyWg%3D%3D",
  "has_more": true
}

updated_since is the polling primitive for a sync integration: reports do not emit webhooks today, so a periodic sweep filtered on last-run time is the supported way to notice new analysis.

Read a report #
GET /v2/reports/{report_id}/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns one report's metadata, executive summary, and the list of analyzed questions with their headlines, summaries, and answer counts. Every other endpoint in this chapter is reached from the ids in this response.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

The report, its questions, and the studies it covers.

FieldTypeDescription
data.iduuidReport identifier.
data.namestringDisplay name of the report.
data.project_iduuidProject the report belongs to.
data.study_idsarray[uuid]Studies whose interviews this report analyzes.
data.summarystringAI-written executive summary of the whole report; empty until the summary stage has run.
data.interview_countintegerNumber of interviews included in this snapshot.
data.average_interview_duration_secondsintegerMean recorded duration of the included interviews, in seconds.
data.is_filteredbooleanWhether the report covers a filtered subset of interviews rather than all of them.
data.filtersobjectThe filter set this snapshot was built with; null for an unfiltered report.
data.emotion_analysis_enabledbooleanWhether any covered study captured emotion analysis, i.e. whether the emotion endpoints will return data.
data.vision_analysis_enabledbooleanWhether any covered study captured vision analysis, i.e. whether the vision endpoints will return data.
data.created_atdatetimeWhen this snapshot was created.
data.last_run_atdatetimeWhen the analysis behind this snapshot last finished writing.
data.latest_runobjectIn-flight or failed run for this report, or null when nothing is running.
data.urlurlDeep link to the report in the Outset web app.
data.questions[].iduuidReport question identifier, used as report_question_id everywhere below.
data.questions[].question_textstringThe question as participants saw it; for a report spanning several studies this is the merged wording.
data.questions[].headlinestringOne-line AI headline answering this question; null before the summary stage runs.
data.questions[].summarystringParagraph-length AI summary of how participants answered; null before the summary stage runs.
data.questions[].sourcestringHow the question was analyzed: TRANSCRIPT_WIDE, STUDY_QUESTION, MULTIPLE_CHOICE, or PARTICIPANT_UPLOAD.
data.questions[].answer_countintegerNumber of analyzed participant answers behind this question.
{
  "data": {
    "id": "9d41c2a7-6b0e-4f38-a1c5-70e2b8d4f911",
    "name": "Spring refresh — packaging concepts",
    "project_id": "b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77",
    "study_ids": [
      "3a7f5c19-24bd-4e08-9c6a-11d3f0e7b452"
    ],
    "summary": "Shoppers read matte finishes as premium but associate the green band with value ranges, which undercuts the freshness cue the redesign is built on.",
    "interview_count": 214,
    "average_interview_duration_seconds": 612,
    "is_filtered": false,
    "filters": null,
    "emotion_analysis_enabled": true,
    "vision_analysis_enabled": false,
    "created_at": "2026-08-09T04:11:07Z",
    "last_run_at": "2026-08-09T04:38:52Z",
    "latest_run": null,
    "url": "https://app.outset.ai/project/b41e7f28-0a6d-4c93-8e5b-2f9d1c604a77/insights",
    "questions": [
      {
        "id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
        "question_text": "What does this packaging tell you about the product inside?",
        "headline": "Freshness reads from the window, not the color band",
        "summary": "Most participants used the transparent window as their freshness cue and treated the green band as a price signal instead.",
        "source": "STUDY_QUESTION",
        "answer_count": 198
      }
    ]
  }
}
Errors
StatusCodeWhen
404not_foundThe report is outside the credential's organization or workspace grant, or it was replaced by a more recent rerun — re-resolve the id from the report list.

Report authoring is deliberately not part of this API: which questions a report analyzes, its research objectives, and its filters are configured in the product. The API reads reports and reruns them.

Rerun a report #
POST /v2/reports/{report_id}/rerun/ proposed
Scope analytics:write Credentials: user-delegated, partner

Regenerates the report from the latest interview data so its findings, summaries, and topline reflect everything fielded since the last run. Returns 202 with a run resource to poll; the finished run names the new report id.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report to regenerate.
Request body

No body. Send an empty object.

Response

202 with the queued (or already-running) analysis run.

FieldTypeDescription
data.iduuidRun identifier; poll the run resource with it.
data.statusstringQUEUED, RUNNING, COMPLETED, or FAILED.
data.was_already_runningbooleanTrue when a run was already in flight and this call joined it instead of starting a second one.
data.started_atdatetimeWhen the run was queued.
{
  "data": {
    "id": "c0a9f6d2-88b1-4a70-9e3d-5b7c2e14a306",
    "status": "QUEUED",
    "was_already_running": false,
    "started_at": "2026-08-11T09:02:14Z"
  }
}
Errors
StatusCodeWhen
409no_data_for_reportThe report has no studies, no questions to analyze, or no completed non-test interviews yet. Retrying will not help until interviews complete.
403permission_deniedThe token holds `analytics:write` but the user's organization role has no editing permission. Reruns consume analysis credits, so they are gated on an editing role as well as the scope.

A rerun replaces the snapshot: when it completes, the previous report and every id inside it (report, questions, answers) are gone and the new run's report_id is the one to use. It is not additive and it is not reversible, so schedule it deliberately rather than on every poll. Calling it while a run is in flight is safe — you get the running one back with was_already_running: true rather than a second run. Report and analysis-template authoring is out of scope for this API; this endpoint refreshes an existing report, it never creates one.

Poll an analysis run #
GET /v2/report-runs/{run_id}/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the state of one analysis run — queued, running with progress, or terminal. This is the poll target after a rerun, and the same object appears as latest_run on a report so you can pick up a run somebody else started.

Path parameters
NameTypeDescription
run_iduuidIdentifier of the run, from a rerun response or a report's latest_run.
Response

The run's current state.

FieldTypeDescription
data.iduuidRun identifier.
data.statusstringQUEUED, RUNNING, COMPLETED, or FAILED. A run that stalls or is canceled reports FAILED.
data.progressfloatFraction of the analysis pipeline completed, between 0 and 1.
data.current_stepintegerIndex of the pipeline stage currently running, from 0.
data.total_stepsintegerNumber of pipeline stages in this run.
data.report_iduuidThe report this run produced; null until the run reaches COMPLETED.
data.requested_by_emailstringEmail of the person or credential owner who triggered the run; null for platform-scheduled refreshes.
data.started_atdatetimeWhen the run was queued.
data.updated_atdatetimeWhen the run last made progress.
{
  "data": {
    "id": "c0a9f6d2-88b1-4a70-9e3d-5b7c2e14a306",
    "status": "RUNNING",
    "progress": 0.42,
    "current_step": 4,
    "total_steps": 11,
    "report_id": null,
    "requested_by_email": "rhian@example.com",
    "started_at": "2026-08-11T09:02:14Z",
    "updated_at": "2026-08-11T09:07:41Z"
  }
}

Analysis takes minutes, not seconds, on a real study — poll every 15–30 seconds rather than tightly. A run resource stays readable for a period after it terminates so a poll loop that reconnects late still sees the outcome; after that the report's last_run_at is the record.

Read the topline insights #
GET /v2/reports/{report_id}/topline/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the report's executive narrative — methodology, research objectives, key findings, and recommendations — as ordered sections plus a single rendered markdown document. This is the shortest path from a finished study to something a stakeholder can read.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

The topline summary as sections and as markdown.

FieldTypeDescription
data.report_iduuidReport the summary belongs to.
data.titlestringTitle of the summary; null when the report has none.
data.markdownstringThe whole summary rendered as one markdown document, title as H1 and each section as H2.
data.sections[].headingstringSection heading, e.g. Methodology or Key Findings.
data.sections[].textstringSection body. Participant quotes are inlined; a non-English quote is served with its English rendering alongside.
data.sections[].orderintegerDisplay order of the section within the summary, from 0.
{
  "data": {
    "report_id": "9d41c2a7-6b0e-4f38-a1c5-70e2b8d4f911",
    "title": "Packaging refresh — what shoppers actually see",
    "markdown": "# Packaging refresh — what shoppers actually see\n\n## Methodology\n\n214 completed interviews across one study…\n\n## Key Findings\n\nFreshness reads from the window, not the color band…",
    "sections": [
      {
        "heading": "Methodology",
        "text": "214 completed interviews across one study…",
        "order": 0
      },
      {
        "heading": "Key Findings",
        "text": "Freshness reads from the window, not the color band…",
        "order": 1
      }
    ]
  }
}
Errors
StatusCodeWhen
404not_foundThe report exists but has no topline summary — the run has not reached the summary stage, or the report is too small to summarize.
Search report answers #
GET /v2/reports/{report_id}/answers/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the analyzed per-participant answers inside a report, each with its AI summary and assigned categories. Narrow with a question filter, a literal keyword, or a semantic query — a plain-language description that is embedded and matched against answers by meaning rather than wording.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Query parameters
NameTypeDescription
report_question_id uuid Restrict answers to one report question.
keyword string Case-insensitive substring match against the answer summary.
semantic_query string Free-text description matched against answers by meaning; results come back ordered by similarity rather than recency.
category string Return only answers tagged with this category, exactly as it appears in the report's category list.
page_size integer · default 50 Number of answers per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated answers, newest first — or most-similar first when semantic_query is set.

FieldTypeDescription
data[].iduuidReport answer identifier; pass it to the source-messages endpoint to get the underlying quotes.
data[].report_question_iduuidReport question this answer belongs to.
data[].interview_iduuidInterview the answer came from; null when the interview has since been deleted.
data[].summarystringAI summary of what this participant said in answer to the question.
data[].categoriesarray[string]Categories this answer is tagged with; empty when the answer is uncategorized.
{
  "data": [
    {
      "id": "2f8e4b6c-71a9-4d05-83bf-6c9a0e17d523",
      "report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
      "interview_id": "7b3c9d51-0e42-4a86-95f7-1d8b6a04c2e9",
      "summary": "Reads the window as proof of freshness and ignores the color band entirely.",
      "categories": [
        "Freshness cues",
        "Ignores color"
      ]
    }
  ],
  "next_cursor": "cD0yMDI2LTA4LTA5VDA0OjM4OjUyWg",
  "has_more": true
}

Answers from interviews whose PII scan has not completed are withheld, and an answer that draws on a message with an unresolved PII flag is dropped while the rest of that interview's answers stay visible. A count taken here is therefore a count of what you are cleared to see, not a participation total — use the report's interview_count or the category summaries for denominators.

List the source messages behind an answer #
GET /v2/reports/{report_id}/answers/{answer_id}/messages/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the interview messages an answer was derived from, with the exact quotes pulled from each. This is the citation trail behind any summary or category in the report.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
answer_iduuidIdentifier of the report answer.
Query parameters
NameTypeDescription
page_size integer · default 50 Number of messages per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated messages and quotes behind the answer, in transcript order; `next_cursor` and `has_more` sit beside `data` on every page.

FieldTypeDescription
data[].message_iduuidInterview message the quotes come from; resolvable against the interview transcript.
data[].quotesarray[string]Verbatim participant quotes extracted from that message.
{
  "data": [
    {
      "message_id": "1c7a94e5-38fd-4b02-90c6-5d7e2a1f4b38",
      "quotes": [
        "I could not tell which address it was going to ship to",
        "so I closed the tab and tried again on my phone"
      ]
    },
    {
      "…": "remaining messages omitted — transcript order"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Individual messages carrying an unresolved PII flag are dropped from this list even when the answer itself is visible, so the quote set can be narrower than the summary implies.

List a question's category summaries #
GET /v2/reports/{report_id}/questions/{report_question_id}/category-summaries/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the per-category rollup for one report question: how many participants fall into each category and, for rating and stack-rank questions, the scores behind it. This is the quantitative backbone of a report — the numbers the charts on the insights page are drawn from.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
report_question_iduuidIdentifier of the report question.
Response

One row per category on the question, in report order. Returned whole — bounded by the report, not paginated.

FieldTypeDescription
data[].categorystringCategory label as it appears in the report.
data[].participant_countintegerNumber of distinct participants whose answer falls into this category — participants, not answers, so a participant with several answers is counted once.
data[].percentagefloatShare of the question's participants in this category, rounded to two decimals.
data[].textstringAI description of what the category represents; null when the category has none.
data[].scorefloatSummed rank score for stack-rank questions; null for other question types.
data[].averagefloatMean numeric value for rating questions; null for other question types.
data[].maxfloatMaximum possible per-participant score, used as the denominator for stack-rank percentages; null for other question types.
{
  "data": [
    {
      "category": "Freshness cues",
      "participant_count": 118,
      "percentage": 59.6,
      "text": "Mentions of transparency, seals, or dates as evidence the product is fresh.",
      "score": null,
      "average": null,
      "max": null
    },
    {
      "category": "Ignores color",
      "participant_count": 47,
      "percentage": 23.74,
      "text": "Did not attribute meaning to the color band.",
      "score": null,
      "average": null,
      "max": null
    }
  ]
}

Percentages are computed against the participants who answered that question, not against everyone in the report, so a question shown to a subset does not read as low participation.

List crosstab comparisons #
GET /v2/reports/{report_id}/comparisons/ proposed
Scope analytics:read Credentials: user-delegated, partner

Replays every comparison configured on the report and returns its crosstab in one call — each question's categories pivoted against another question's categories, against the study an interview belongs to, or against a participant metadata key. This is the general crosstab surface behind the insights page's comparison view.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

One entry per report question that has a comparison configured. Returned whole — bounded by the report, not paginated.

FieldTypeDescription
data[].base_report_question_iduuidReport question whose categories form the rows of the crosstab.
data[].comparison_typestringAxis the base question is pivoted against: REPORT_QUESTION, STUDY, or METADATA.
data[].comparison_report_question_iduuidReport question supplying the columns, when comparison_type is REPORT_QUESTION; otherwise absent.
data[].comparison_metadata_keystringParticipant metadata key supplying the columns, when comparison_type is METADATA; otherwise absent.
data[].labelsarray[string]Column labels of the crosstab, in display order.
data[].cells[].labelstringRow label — a category of the base question.
data[].cells[].comparison_labelstringColumn label — a category, study name, or metadata value.
data[].cells[].countintegerParticipants in this row-and-column cell.
data[].cells[].percentagefloatThe cell's share of its column.
data[].cells[].study_iduuidStudy the column represents, when comparison_type is STUDY; otherwise absent.
data[].total_respondentsintegerDistinct participants across the whole crosstab, for METADATA comparisons.
data[].comparison_respondent_countsarray[object]Distinct participants per column, as {comparison_label, count} rows, for METADATA comparisons.
{
  "data": [
    {
      "base_report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
      "comparison_type": "METADATA",
      "comparison_metadata_key": "shopping_frequency",
      "labels": [
        "Weekly",
        "Monthly"
      ],
      "cells": [
        {
          "label": "Freshness cues",
          "comparison_label": "Weekly",
          "count": 74,
          "percentage": 63.8
        },
        {
          "label": "Freshness cues",
          "comparison_label": "Monthly",
          "count": 44,
          "percentage": 53.7
        }
      ],
      "total_respondents": 198,
      "comparison_respondent_counts": [
        {
          "comparison_label": "Weekly",
          "count": 116
        },
        {
          "comparison_label": "Monthly",
          "count": 82
        }
      ]
    }
  ]
}

Cell counts are per category-and-column pair, so a participant who selected several options appears in more than one cell — never sum cells to derive a participant total, use total_respondents and comparison_respondent_counts instead. Which comparison each question carries is configured in the product; this endpoint reads the result, it does not set it.

List comparison matrices #
GET /v2/reports/{report_id}/comparison-matrices/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the report's matrix-style comparisons — concepts or questions as rows, attributes as columns, one summarized cell per intersection. This is the shape behind concept-wide and question-by-question overview tables.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

One entry per matrix on the report, in display order. Returned whole — bounded by the report, not paginated.

FieldTypeDescription
data[].iduuidMatrix identifier.
data[].namestringDisplay name of the matrix.
data[].typestringQUESTION_BY_QUESTION_OVERVIEW, CONCEPT_WIDE_ANALYSIS, or USER_DEFINED.
data[].orderintegerDisplay order among the report's matrices, from 0.
data[].items[].iduuidRow identifier.
data[].items[].namestringRow label — usually a concept or a question.
data[].attributes[].iduuidColumn identifier.
data[].attributes[].namestringColumn heading, which a researcher may have renamed.
data[].cells[].item_iduuidRow this cell belongs to.
data[].cells[].attribute_iduuidColumn this cell belongs to.
data[].cells[].report_question_iduuidReport question the cell summarizes; null when the cell is not question-backed.
data[].cells[].valuestringCell contents — a short summary, a score, or a label depending on the matrix type.
data[].cells[].box_dataobjectDistribution behind a scale cell (for example top-box and bottom-box shares); null when the cell is not scale-backed.
{
  "data": [
    {
      "id": "f2b60d94-51ae-4c37-8b90-6e4a1d75c082",
      "name": "Concept-wide analysis",
      "type": "CONCEPT_WIDE_ANALYSIS",
      "order": 0,
      "items": [
        {
          "id": "7a3d5e21-9c48-4f60-b1d7-2e8c05a3f914",
          "name": "Matte green band"
        }
      ],
      "attributes": [
        {
          "id": "c0e91b74-2d85-4a13-9f62-8b7d3c1e05a6",
          "name": "Perceived freshness"
        }
      ],
      "cells": [
        {
          "item_id": "7a3d5e21-9c48-4f60-b1d7-2e8c05a3f914",
          "attribute_id": "c0e91b74-2d85-4a13-9f62-8b7d3c1e05a6",
          "report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
          "value": "Read as value-range rather than fresh",
          "box_data": null
        },
        {
          "…": "one cell per row-and-column pair"
        }
      ]
    },
    {
      "…": "remaining matrices omitted — display order"
    }
  ]
}
Read the emotion crosstab #
GET /v2/reports/{report_id}/emotion-crosstab/ proposed
Scope analytics:read Credentials: user-delegated, partner

Pivots one question's dominant-emotion breakdown against a comparison axis — a participant metadata key, another question's categories, or the study an interview belongs to. Each participant is counted once per question, using the emotion that dominated their answer.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Query parameters
NameTypeDescription
report_question_id required uuid Report question whose dominant-emotion breakdown you want.
comparison_type required string Axis to split the breakdown by: METADATA, REPORT_QUESTION, or STUDY.
metadata_key string Required when comparison_type is METADATA: the participant metadata key to split by.
comparison_report_question_id uuid Required when comparison_type is REPORT_QUESTION: the question whose answer categories split the breakdown.
comparison_axis string Only valid with comparison_type REPORT_QUESTION; CATEGORY splits by the comparison question's categories.
Response

The pivot table, one cell per emotion-and-column pair.

FieldTypeDescription
data.report_question_iduuidReport question the breakdown is for.
data.comparison_typestringAxis the breakdown was split by, echoed back.
data.comparison_labelstringHuman-readable name of the comparison axis, for chart labelling.
data.total_participantsintegerParticipants counted in the pivot.
data.cells[].emotionstringDominant emotion: anger, contempt, disgust, fear, happiness, sadness, surprise, or neutral.
data.cells[].comparison_valuestringValue of the comparison axis for this cell — a metadata value, a category, or a study name.
data.cells[].countintegerParticipants whose dominant emotion was this one within that column.
data.cells[].percentagefloatThe cell's share of its column.
{
  "data": {
    "report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
    "comparison_type": "METADATA",
    "comparison_label": "Shopping frequency",
    "total_participants": 186,
    "cells": [
      {
        "emotion": "happiness",
        "comparison_value": "Weekly",
        "count": 61,
        "percentage": 54.5
      },
      {
        "emotion": "surprise",
        "comparison_value": "Weekly",
        "count": 22,
        "percentage": 19.6
      },
      {
        "emotion": "neutral",
        "comparison_value": "Monthly",
        "count": 39,
        "percentage": 53.4
      }
    ]
  }
}

A participant with no recorded emotional signal on the question counts as neutral, matching how the product's charts read. Questions with no underlying guide question — research-objective rollups, for instance — have no per-participant emotion verdict and return an empty cell list rather than an error.

Read the report's emotion aggregation #
GET /v2/reports/{report_id}/emotion-summary/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the whole report's emotion rollup in one call: the dominant emotion, per-emotion counts, and shares for every question, and for every concept when the report covers concept testing. Use it for the report-level emotional picture; use the crosstab when you need one question split by a segment.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

The emotion aggregation, or null when no covered study captured emotion analysis.

FieldTypeDescription
data.statusstringState of the aggregation: QUEUED, RUNNING, COMPLETED, or FAILED, plus NOT_STARTED when aggregation has never been attempted. Anything but COMPLETED means the numbers are not final.
data.total_participantsintegerParticipants with an emotion verdict anywhere in the report.
data.question_breakdowns[].report_question_iduuidReport question the breakdown is for.
data.question_breakdowns[].dominant_emotionstringMost common dominant emotion on this question.
data.question_breakdowns[].participant_countintegerParticipants counted in this breakdown.
data.question_breakdowns[].emotion_countsarray[object]Participants per emotion, as {emotion, count} rows.
data.question_breakdowns[].emotion_percentagesarray[object]Share per emotion, as {emotion, percentage} rows.
data.concept_breakdowns[].concept_namestringConcept the breakdown is for; present only on concept-testing reports.
data.concept_breakdowns[].dominant_emotionstringMost common dominant emotion toward this concept.
data.concept_breakdowns[].participant_countintegerParticipants counted in this concept breakdown.
data.concept_breakdowns[].emotion_countsarray[object]Participants per emotion for this concept, as {emotion, count} rows.
{
  "data": {
    "status": "COMPLETED",
    "total_participants": 186,
    "question_breakdowns": [
      {
        "report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
        "dominant_emotion": "happiness",
        "participant_count": 112,
        "emotion_counts": [
          {
            "emotion": "happiness",
            "count": 61
          },
          {
            "emotion": "surprise",
            "count": 22
          }
        ],
        "emotion_percentages": [
          {
            "emotion": "happiness",
            "percentage": 54.5
          },
          {
            "emotion": "surprise",
            "percentage": 19.6
          }
        ]
      },
      {
        "…": "one breakdown per report question"
      }
    ],
    "concept_breakdowns": [
      {
        "concept_name": "Matte green band",
        "dominant_emotion": "neutral",
        "participant_count": 74,
        "emotion_counts": [
          {
            "emotion": "neutral",
            "count": 39
          }
        ]
      }
    ]
  }
}

Emotion analysis runs on answer recordings, so a text-only study returns null here even when everything else in the report is complete. Check emotion_analysis_enabled on the report before treating an empty response as a failure.

Search emotion observations #
GET /v2/reports/{report_id}/emotion-observations/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the individual emotional moments the analysis identified in participant answer recordings, each with the evidence behind it and its position in the recording. Use this when you need the moments themselves; use the emotion summary or crosstab for quantities.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Query parameters
NameTypeDescription
report_question_id uuid Restrict observations to one report question.
keyword string Case-insensitive match against observation titles and evidence.
emotion_label string One of anger, contempt, disgust, fear, happiness, sadness, surprise, neutral.
priority string How notable the moment is: low, medium, or high.
confidence string Model confidence in the reading: low, medium, or high.
page_size integer · default 50 Number of observations per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated observations, newest first.

FieldTypeDescription
data[].iduuidObservation identifier.
data[].titlestringShort label for the moment.
data[].evidencestringWhat the participant said or did that supports the reading.
data[].emotion_labelstringThe emotion read at this moment.
data[].prioritystringHow notable the moment is: low, medium, or high.
data[].confidencestringModel confidence in the reading: low, medium, or high.
data[].interview_iduuidInterview the moment occurred in.
data[].message_iduuidInterview message the moment sits in; null when it cannot be attributed to one.
data[].study_question_iduuidStudy question being answered at the time; null when unattributed.
data[].start_secondsfloatStart of the moment within the answer recording, in seconds.
data[].end_secondsfloatEnd of the moment within the answer recording, in seconds.
{
  "data": [
    {
      "id": "84c1f6b0-72d5-4e19-9a83-0c5b7e2d146f",
      "title": "Relief once the saved address appears",
      "evidence": "Participant exhales and smiles as the saved address auto-fills.",
      "emotion_label": "happiness",
      "priority": "medium",
      "confidence": "high",
      "interview_id": "6e2b0a37-4c81-49d5-b7f3-1a90d5c8e264",
      "message_id": "1c7a94e5-38fd-4b02-90c6-5d7e2a1f4b38",
      "study_question_id": "a7c30f18-4d92-4b6a-9f57-0e1b8c2d4a63",
      "start_seconds": 74.5,
      "end_seconds": 79.2
    },
    {
      "…": "remaining observations omitted — newest first"
    }
  ],
  "next_cursor": "cD0yMDI2LTA4LTA5VDA0OjExOjA3Wg%3D%3D",
  "has_more": true
}

Observations are derived from recordings, which are not independently PII-scanned — so every observation from an interview that is not fully PII-cleared is withheld, not masked. A participant missing here may simply be under review.

Read aggregated vision insights #
GET /v2/reports/{report_id}/questions/{report_question_id}/vision-insights/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the cross-participant vision aggregation for one question: clustered behavioral patterns with participant counts, plus the question's aggregate usability metrics — ease of use, task completion as judged by the AI against what participants said about themselves, and average task duration. This is the endpoint to use for quantities; the raw observations are a separate read.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
report_question_iduuidIdentifier of the report question.
Response

The aggregation and its clustered patterns.

FieldTypeDescription
data.statusstringState of the aggregation: QUEUED, RUNNING, COMPLETED, or FAILED, plus NOT_STARTED when aggregation has never been attempted. Anything but COMPLETED means the numbers are not final.
data.total_participantsintegerParticipants who reached this question.
data.participants_with_observationsintegerParticipants for whom the analysis found at least one notable moment.
data.avg_ease_of_usefloatMean ease-of-use rating across observed participants; null when unmeasured.
data.ai_task_complete_countintegerParticipants the AI judged to have completed the task, with any researcher override applied; null when the question is not a task.
data.ai_task_incomplete_countintegerParticipants the AI judged not to have completed the task; null when the question is not a task.
data.self_report_complete_countintegerParticipants who said they completed the task; compare against the AI counts for the say-do gap.
data.self_report_incomplete_countintegerParticipants who said they did not complete the task.
data.avg_task_duration_secondsfloatMean time on task in seconds; null when unmeasured.
data.insights[].titlestringName of the behavioral pattern.
data.insights[].descriptionstringWhat participants did, and why it matters.
data.insights[].insight_typestringfriction_pattern, success_pattern, or unexpected_pattern.
data.insights[].participant_countintegerParticipants exhibiting this pattern.
data.insights[].total_participantsintegerParticipants the pattern was assessed against, as the denominator.
data.insights[].avg_ease_of_usefloatMean ease-of-use rating among participants in this pattern; null when unmeasured.
data.insights[].common_surface_areasarray[string]Parts of the interface the pattern concentrated on.
data.insights[].representative_observation_idsarray[uuid]Observation ids illustrating the pattern; resolve them through the observation search.
{
  "data": {
    "status": "COMPLETED",
    "total_participants": 42,
    "participants_with_observations": 37,
    "avg_ease_of_use": 3.4,
    "ai_task_complete_count": 24,
    "ai_task_incomplete_count": 18,
    "self_report_complete_count": 33,
    "self_report_incomplete_count": 9,
    "avg_task_duration_seconds": 96.2,
    "insights": [
      {
        "title": "Checkout entry point missed on first pass",
        "description": "Participants scanned the header for a cart affordance and only found the inline button after scrolling back.",
        "insight_type": "friction_pattern",
        "participant_count": 14,
        "total_participants": 42,
        "avg_ease_of_use": 2.6,
        "common_surface_areas": [
          "Header",
          "Product detail"
        ],
        "representative_observation_ids": [
          "a19c7e35-4d80-4b12-9f6a-2c5e8d0b41f7"
        ]
      }
    ]
  }
}

A say-do gap is the point of the AI-versus-self-report counts: they are computed from different sources and are meant to disagree. The AI counts already reflect any researcher override recorded in the product, so they can move without the report being rerun.

Search vision observations #
GET /v2/reports/{report_id}/vision-observations/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the individual usability moments the vision analysis identified during interviews — friction points, successes, and unexpected behaviors — with where in the interface and where in the recording each occurred.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Query parameters
NameTypeDescription
report_question_id uuid Restrict observations to one report question.
keyword string Case-insensitive match against observation titles and descriptions.
observation_type string One of success, friction, unexpected.
page_size integer · default 50 Number of observations per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated observations, newest first.

FieldTypeDescription
data[].iduuidObservation identifier.
data[].titlestringShort label for what happened.
data[].descriptionstringWhat the participant did, in the analysis's words.
data[].observation_typestringsuccess, friction, or unexpected.
data[].ease_of_useintegerEase-of-use rating attached to this moment; null when unrated.
data[].surface_areastringPart of the interface the moment happened on; null when unattributed.
data[].interview_iduuidInterview the moment occurred in.
data[].study_question_iduuidStudy question in play at the time; null when unattributed.
data[].start_secondsintegerStart of the moment within the recording, in seconds.
data[].end_secondsintegerEnd of the moment within the recording, in seconds.
{
  "data": [
    {
      "id": "2f8b7d16-05ac-4931-8e42-b6c1a7f309d5",
      "title": "Misses the promo-code field",
      "description": "Participant scrolls past the collapsed promo-code row twice before finding it.",
      "observation_type": "friction",
      "ease_of_use": 2,
      "surface_area": "Checkout — payment step",
      "interview_id": "6e2b0a37-4c81-49d5-b7f3-1a90d5c8e264",
      "study_question_id": "a7c30f18-4d92-4b6a-9f57-0e1b8c2d4a63",
      "start_seconds": 141,
      "end_seconds": 168
    },
    {
      "…": "remaining observations omitted — newest first"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Like emotion observations, these are recording-derived: any interview that is not fully PII-cleared is withheld wholesale rather than partially masked.

Search task-completion verdicts #
GET /v2/reports/{report_id}/vision-task-results/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns one row per participant and task question: whether the AI judged the task complete, whether a researcher overrode that, and why the AI and the participant's own answer disagreed when they did. This is the per-participant detail behind the say-do gap.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Query parameters
NameTypeDescription
report_question_id uuid Restrict verdicts to one report question.
task_completed boolean Filter on the effective verdict — the researcher override when one exists, otherwise the AI's.
only_with_discrepancy boolean · default False Return only rows where the AI verdict disagreed with what the participant said about themselves.
page_size integer · default 50 Number of verdicts per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

Cursor-paginated verdicts, newest first.

FieldTypeDescription
data[].iduuidVerdict identifier.
data[].interview_iduuidInterview the verdict is for.
data[].study_question_iduuidStudy question whose task was judged; null when unattributed.
data[].task_completedbooleanThe AI's raw verdict, before any researcher override.
data[].effective_task_completedbooleanThe verdict that counts: the researcher override when one exists, otherwise the AI's.
data[].has_researcher_overridebooleanWhether a researcher replaced the AI verdict.
data[].discrepancy_reasonstringWhy the AI verdict and the participant's self-report disagreed; null when they agreed.
{
  "data": [
    {
      "id": "0a53c9e4-71b6-4d28-95f1-c3e8b204a7d6",
      "interview_id": "6e2b0a37-4c81-49d5-b7f3-1a90d5c8e264",
      "study_question_id": "a7c30f18-4d92-4b6a-9f57-0e1b8c2d4a63",
      "task_completed": false,
      "effective_task_completed": true,
      "has_researcher_override": true,
      "discrepancy_reason": "Participant said they finished, but the order confirmation never rendered on screen."
    },
    {
      "…": "remaining verdicts omitted — newest first"
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Read a prototype interaction map #
GET /v2/reports/{report_id}/questions/{report_question_id}/interaction-map/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the frozen interaction map for a question backed by a Figma prototype: per-screen click and rage-click geometry, dwell time, navigation confusion, the deterministic metric tiles the insights page shows, and the AI's per-screen narration. This is the richest analysis layer Outset produces and it has no equivalent anywhere else in the API.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
report_question_iduuidIdentifier of the report question.
Response

The interaction map, its metric tiles, and its narration.

FieldTypeDescription
data.participant_countintegerParticipants whose sessions are aggregated into the map.
data.screens[].idstringPrototype screen identifier, stable across the other blocks in this response.
data.screens[].namestringScreen name as it appears in the prototype.
data.screens[].image_urlurlShort-lived signed URL to the rendered screen image, to draw the heatmap over. Expires within minutes — fetch it when you render, do not persist it.
data.screens[].design_dimensionsobjectWidth and height of the screen image in design pixels; click coordinates share this space.
data.screens[].clicksarray[object]Click points as {x, y} in design pixels, capped per screen on very high-traffic studies.
data.screens[].clicks_truncatedbooleanWhether the click list hit the per-screen cap; the density picture is unaffected, the tail of the session trail is.
data.screens[].rage_clicksarray[object]Rapid repeated-click clusters, as centroid plus cluster size — computed from the full click set, never truncated.
data.screens[].dwellobjectAverage time on this screen and how many sessions it was measured across; null when unmeasured.
data.screens[].navigationobjectHow many sessions visited the screen and how many came back to it, the basis of the navigation-confusion reading; null when the screen saw no navigation.
data.metrics[].screen_idstringScreen these metric tiles belong to.
data.metrics[].avg_dwell_msfloatAverage dwell in milliseconds; null when unmeasured.
data.metrics[].rage_click_countintegerTotal rage clicks on the screen.
data.metrics[].nav_confusionstringNavigation-confusion reading for the screen: Low, Medium, or High; null when unmeasured.
data.findings[].screen_idstringScreen the narration is about.
data.findings[].summarystringOne-paragraph read of what happened on this screen.
data.findings[].findings[].titlestringShort label for a specific finding on the screen.
data.findings[].findings[].detailstringWhat participants did and where.
data.findings[].findings[].quotesarray[object]Supporting participant quotes, as {text, participant} rows.
{
  "data": {
    "participant_count": 38,
    "screens": [
      {
        "id": "412:9075",
        "name": "Product detail",
        "image_url": "https://files.outset.ai/figma/412-9075.png?X-Amz-Expires=600&…",
        "design_dimensions": {
          "width": 390,
          "height": 1284
        },
        "clicks": [
          {
            "x": 196,
            "y": 742
          },
          "…"
        ],
        "clicks_truncated": false
      },
      "…"
    ],
    "metrics": [
      {
        "screen_id": "412:9075",
        "avg_dwell_ms": 18400.0,
        "rage_click_count": 6,
        "nav_confusion": "Medium"
      },
      "…"
    ],
    "findings": [
      "…"
    ]
  }
}
Errors
StatusCodeWhen
404not_foundThe question has no prototype interaction map — it is not a Figma-prototype question, or the prototype capture failed or is still in flight for this study.

The map is frozen into the report snapshot at run time, so it is stable and citable, but it only reflects the interviews included in that run — a rerun rebuilds it. Screen images are delivered as short-lived signed URLs; store the screen id, not the URL.

Read research-objective conclusions #
GET /v2/reports/{report_id}/research-goals/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns one entry per research objective configured on the report, with the conclusion the analysis reached, the recommendations that follow, and the supporting themes. This is the answer to "did we learn what we set out to learn", separate from the per-question breakdown.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

The report's research objectives and their conclusions, in configured order. Returned whole — bounded by the report, not paginated.

FieldTypeDescription
data[].iduuidResearch-goal identifier within this report snapshot.
data[].objectivestringThe research objective as written when the report ran.
data[].conclusionsarray[string]The conclusions the analysis reached against this objective, as ordered paragraphs.
data[].recommendationsarray[string]Recommended actions that follow from the conclusions.
data[].interviews_consideredintegerInterviews that contributed evidence to this objective.
data[].report_question_iduuidReport question generated for this objective, if any — read its answers for the underlying evidence.
data[].orderintegerDisplay order among the report's objectives, from 0.
data[].themes[].titlestringSupporting theme's name.
data[].themes[].summarystringWhat the theme says, in the analysis's words.
data[].themes[].participant_countintegerParticipants whose answers support the theme.
data[].themes[].orderintegerDisplay order of the theme within its objective, from 0.
{
  "data": [
    {
      "id": "b7e40c18-6a29-4f53-90d1-8c2e5b7a1f34",
      "objective": "Find the steps where shoppers abandon checkout",
      "conclusions": [
        "Address entry is where most shoppers stall; the saved-address prompt arrives too late to help."
      ],
      "recommendations": [
        "Surface saved addresses before the form renders.",
        "Keep the promo-code field expanded on the payment step."
      ],
      "interviews_considered": 198,
      "report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
      "order": 0,
      "themes": [
        {
          "title": "Address entry stalls the flow",
          "summary": "Shoppers retype addresses they had already saved, then lose confidence in the order.",
          "participant_count": 63,
          "order": 0
        }
      ]
    },
    {
      "…": "remaining objectives omitted — configured order"
    }
  ]
}

Objectives are captured at run time, so a report keeps answering the objectives it was run against even after somebody edits them in the product — the wording here is the wording the analysis actually used.

List concept groups #
GET /v2/reports/{report_id}/concept-groups/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the concept groups a concept-testing report covers — each tested concept with the stimulus participants saw and, when emotion analysis ran, its emotional reception. Empty for a report with no concept testing.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

The report's concept groups, in display order. Returned whole — bounded by the report, not paginated.

FieldTypeDescription
data[].iduuidConcept group identifier.
data[].namestringInternal name of the concept group.
data[].display_namestringName as participants and readers see it.
data[].orderintegerDisplay order among the report's concept groups, from 0.
data[].concepts[].iduuidConcept identifier within the group.
data[].concepts[].namestringConcept name.
data[].concepts[].fieldsarray[object]The concept's defined fields and values, as {name, value} rows — the copy, price, or claim under test.
data[].concepts[].stimulus_urlurlShort-lived signed URL to the image or file participants were shown; null when the concept has no stimulus.
data[].emotion_breakdowns[].dominant_emotionstringMost common dominant emotion toward this concept; present only when emotion analysis ran.
data[].emotion_breakdowns[].participant_countintegerParticipants counted in the concept's emotion breakdown.
{
  "data": [
    {
      "id": "3d9a1c76-4b02-48e5-9f37-a1c6b05d287e",
      "name": "packaging_v3",
      "display_name": "Packaging refresh",
      "order": 0,
      "concepts": [
        {
          "id": "7a3d5e21-9c48-4f60-b1d7-2e8c05a3f914",
          "name": "Matte green band",
          "fields": [
            {
              "name": "Claim",
              "value": "Picked at peak freshness"
            }
          ],
          "stimulus_url": "https://media.outset.ai/stimuli/7a3d5e21…?X-Amz-Expires=900&…"
        }
      ],
      "emotion_breakdowns": [
        {
          "dominant_emotion": "neutral",
          "participant_count": 74
        }
      ]
    },
    {
      "…": "remaining concept groups omitted — display order"
    }
  ]
}
Read one concept group #
GET /v2/reports/{report_id}/concept-groups/{concept_group_id}/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns one concept group with every report question scoped to it — the per-concept view of the report, so a monadic design can be read concept by concept instead of question by question.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
concept_group_iduuidIdentifier of the concept group.
Response

The concept group and its questions.

FieldTypeDescription
data.concept_groupobjectThe concept group, in the same shape the list returns.
data.questions[].iduuidReport question identifier, scoped to this concept.
data.questions[].question_textstringThe question as participants saw it.
data.questions[].headlinestringOne-line AI headline for this concept's answers; null before the summary stage runs.
data.questions[].summarystringParagraph-length AI summary for this concept's answers.
data.questions[].answer_countintegerAnalyzed answers behind the question for this concept.
{
  "data": {
    "concept_group": {
      "id": "3d9a1c76-4b02-48e5-9f37-a1c6b05d287e",
      "name": "packaging_v3",
      "display_name": "Packaging refresh",
      "order": 0
    },
    "questions": [
      {
        "id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
        "question_text": "What does this packaging tell you about the product inside?",
        "headline": "Freshness reads from the window, not the color band",
        "summary": "Participants used the transparent window as their freshness cue and treated the green band as a price signal.",
        "answer_count": 74
      },
      {
        "…": "remaining questions omitted — one per report question scoped to this concept group"
      }
    ]
  }
}

Question ids here are the same ids the report read returns, so category summaries, comparisons, and answer searches all accept them unchanged.

List participant-upload insights #
GET /v2/reports/{report_id}/questions/{report_question_id}/participant-uploads/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the rollup for a participant-upload question — how many participants uploaded, what share passed AI validation, and every upload with its AI description and assigned tags.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
report_question_iduuidIdentifier of the report question; it must be a participant-upload question.
Query parameters
NameTypeDescription
tag string Return only uploads carrying this AI-assigned tag. Repeatable.
page_size integer · default 50 Number of uploads per page, up to 200.
cursor string Opaque cursor from the previous page's next_cursor.
Response

The question's upload rollup and rows. `data.uploads` is cursor-paginated, with `next_cursor` and `has_more` beside `data`; the rollup figures cover the whole question rather than the page.

FieldTypeDescription
data.total_uploadsintegerUploads submitted for this question across the report's interviews.
data.ai_validation_ratefloatShare of uploads that passed AI validation against the question's requirements, between 0 and 1.
data.uploads[].iduuidUpload identifier within this report snapshot.
data.uploads[].interview_iduuidInterview the upload came from.
data.uploads[].media_typestringKind of file uploaded: IMAGE, VIDEO, or DOCUMENT.
data.uploads[].content_typestringMIME type of the uploaded file.
data.uploads[].file_urlurlShort-lived signed URL to the uploaded file; expires within minutes and must not be persisted.
data.uploads[].validation_statusstringPASSED when the upload met the question's requirements, FLAGGED when it did not, QUEUED while validation has not finished.
data.uploads[].tagsarray[string]AI-assigned tags describing what is in the upload.
data.uploads[].summarystringAI description of the upload's contents; null when the analysis produced none.
data.uploads[].study_iduuidStudy the uploading participant took part in — worth carrying on a report spanning several studies, since participant numbering restarts per study.
{
  "data": {
    "total_uploads": 142,
    "ai_validation_rate": 0.87,
    "uploads": [
      {
        "id": "e91c47b3-2d68-4a05-9713-5f0b8c2e6a4d",
        "interview_id": "6e2b0a37-4c81-49d5-b7f3-1a90d5c8e264",
        "media_type": "IMAGE",
        "content_type": "image/jpeg",
        "file_url": "https://media.outset.ai/uploads/e91c47b3…?X-Amz-Expires=900&…",
        "validation_status": "PASSED",
        "tags": [
          "pantry shelf",
          "competitor product"
        ],
        "summary": "A pantry shelf with three cereal boxes, the study's brand on the left.",
        "study_id": "3a7f5c19-24bd-4e08-9c6a-11d3f0e7b452"
      },
      {
        "…": "remaining uploads omitted"
      }
    ]
  },
  "next_cursor": "cD0yMDI2LTA4LTA5VDA0OjExOjA3Wg%3D%3D",
  "has_more": true
}
Errors
StatusCodeWhen
400validation_errorThe report question is not a participant-upload question. Use the answer search for every other question type.

Uploads are not independently PII-scanned, so uploads from an interview that is not fully PII-cleared are withheld and the totals reflect only what you are cleared to see.

List a report's filter options #
GET /v2/reports/{report_id}/filter-options/ proposed
Scope analytics:read Credentials: user-delegated, partner

Enumerates every axis a report can be segmented on: the participant metadata keys and their observed values, the studies the report covers, and each question with the categories its answers were tagged with. Use it to discover valid values before filtering answers or requesting a crosstab, instead of guessing at keys.

Path parameters
NameTypeDescription
report_iduuidIdentifier of the report.
Response

The report's filterable axes. Returned whole — bounded by the report, not paginated.

FieldTypeDescription
data.metadata_items[].keystringParticipant metadata key present on this report's interviews — a valid metadata_key for the emotion crosstab.
data.metadata_items[].valuesarray[string]Distinct values observed for that key.
data.study_items[].study_iduuidStudy included in the report.
data.study_items[].study_namestringName of that study.
data.question_items[].report_question_iduuidReport question that can be filtered on.
data.question_items[].question_textstringThe question's wording.
data.question_items[].categoriesarray[string]Categories its answers were tagged with — valid values for the answer search's category filter.
{
  "data": {
    "metadata_items": [
      {
        "key": "shopping_frequency",
        "values": [
          "Weekly",
          "Monthly"
        ]
      }
    ],
    "study_items": [
      {
        "study_id": "3a7f5c19-24bd-4e08-9c6a-11d3f0e7b452",
        "study_name": "Packaging concepts — wave 1"
      }
    ],
    "question_items": [
      {
        "report_question_id": "5e0b8a31-97c4-4d2f-b6e8-3a1f7c05d288",
        "question_text": "What does this packaging tell you about the product inside?",
        "categories": [
          "Freshness cues",
          "Ignores color"
        ]
      }
    ]
  }
}
Chapter 14

Highlight Reels & Clips #

A highlight reel is a rendered video built from trimmed clips of participant recordings — the shareable artifact at the end of an analysis. Building one is a two-step flow: search a project's interviews for the clips worth keeping, then ask Outset to stitch a selection of them into one reel. Rendering is asynchronous, so creation answers 202 and you poll the reel until it reports COMPLETED, at which point its media, its automatically generated captions, and its poster frame are readable through short-lived presigned URLs. Reels can also be minted from an existing report — every quote tagged with one category of a question, one emotion drill-down, or a single emotion observation. Share links are one of two deliberate exceptions to short-lived URLs: a share link is a durable public URL anyone can open without signing in, and it stays valid until it expires or you revoke it.

Search highlight clips #
GET /v2/projects/{project_id}/highlight-clips/ proposed
Scope analytics:read Credentials: user-delegated, partner

Finds candidate clips across every interview in a project — the picker that feeds reel creation. Search semantically with vector_text, literally with keyword_text, or both together to narrow. Only answers that have a recording behind them are returned, since a clip without media cannot be rendered.

Path parameters
NameTypeDescription
project_iduuidProject whose interviews to search.
Query parameters
NameTypeDescription
vector_text string Natural-language description of the clips you want; it is embedded and matched by meaning rather than wording. Must be at least 10 characters after trimming.
keyword_text string Case-insensitive substring match over the clip text. No query syntax — it is a plain contains match.
study_ids uuid, repeatable Narrow the search to specific studies in the project. Every ID must belong to this project; an ID that does not is an error rather than a silent skip.
limit integer · default 10 Maximum number of clips to return, between 1 and 50.
Response

Clips ranked by match quality, best first. This is a ranked search rather than a page of rows, so the response is a plain list with no cursor.

FieldTypeDescription
data[].message_iduuidInterview message the clip is trimmed from — this is the ID you pass to reel creation.
data[].interview_iduuidInterview the clip came from, or `null` when the answer is not attributed to one.
data[].study_iduuidStudy the interview belongs to.
data[].report_question_textstringText of the report question the clip answers, for showing the clip in context.
data[].excerptstringFirst 200 characters of what the participant said, as a preview of the clip.
data[].categoriesarray[string]Categories the report assigned to this answer, or `null` when the answer was never categorized.
data[].similarity_scorefloatRelevance of this clip to `vector_text`, higher is closer. Always `1.0` for a keyword-only search, where results are not ranked by meaning.
{
  "data": [
    {
      "message_id": "b4f0d6a2-18c7-4f53-9a61-2d7e0c5b83f4",
      "interview_id": "51c9e7b3-6a02-4d18-bf74-9e3a1c6d2085",
      "study_id": "0d3b8f16-72ae-4c95-8b30-6f1c4a9d7e52",
      "report_question_text": "What made you hesitate before checking out?",
      "excerpt": "I got to the payment step and there was suddenly a shipping fee I hadn't seen anywhere, so I closed the tab.",
      "categories": [
        "Unexpected cost",
        "Checkout friction"
      ],
      "similarity_score": 0.71
    }
  ]
}
Errors
StatusCodeWhen
400validation_errorNeither `vector_text` nor `keyword_text` was given, `vector_text` is shorter than 10 characters, or a `study_ids` value does not belong to this project.

Clips are cut from recordings, which are not scanned for PII independently of their interview. Answers from an interview whose PII review has not cleared are therefore excluded from search results entirely, so a reel can never be built from unreviewed media.

Create a highlight reel #
POST /v2/projects/{project_id}/highlight-reels/ proposed
Scope analytics:write Credentials: user-delegated, partner

Stitches selected clips into one reel, in the order you list them. Returns 202 with the reel in QUEUED — rendering runs in the background, so poll the reel until it reports COMPLETED before expecting media.

Path parameters
NameTypeDescription
project_iduuidProject the reel belongs to; every clip must come from an interview in this project.
Request body

The reel's name and the clips it contains.

FieldTypeDescription
title required string Name shown for the reel, and rendered as its title slide. Truncated at 255 characters.
message_ids required array[uuid] Interview messages to include, between 1 and 25, in the order they should play. Use the `message_id` values returned by highlight-clip search.
{
  "title": "Checkout friction — top moments",
  "message_ids": [
    "b4f0d6a2-18c7-4f53-9a61-2d7e0c5b83f4",
    "9e51c07d-3b48-4a26-8f19-c4d2065ba7e3"
  ]
}
Response

The reel as created, in the same shape the read endpoint returns.

FieldTypeDescription
data.iduuidReel ID; poll it at `GET /v2/highlight-reels/{reel_id}/`.
data.project_iduuidProject the reel belongs to.
data.titlestringName of the reel.
data.statusenum`QUEUED` on creation; it moves to `RUNNING`, then `COMPLETED`, `FAILED`, or `NO_RESULTS`.
data.clips_countintegerNumber of clips in the reel.
data.created_atdatetimeWhen the reel was created (ISO 8601, UTC).
{
  "data": {
    "id": "7c31a0e8-5d64-4b92-8f07-1a6e3c9d4b25",
    "project_id": "2f8b41c9-0e76-4d3a-95b8-c70d51a6f284",
    "title": "Checkout friction — top moments",
    "status": "QUEUED",
    "clips_count": 2,
    "created_at": "2026-08-11T09:14:22Z"
  }
}
Errors
StatusCodeWhen
400validation_errorA message ID is not in this project, has no recording behind it, or comes from an interview whose PII review has not cleared. The response names the offending IDs; nothing is created.

Rendering costs transcoding time, and each call creates a new reel — a repeated request is a second reel, not a retry of the first. A reel is only ever as clean as its sources: a clip from an interview still under PII review is refused outright rather than dropped from the selection, so the reel you get always contains exactly the clips you asked for.

Read a highlight reel #
GET /v2/highlight-reels/{reel_id}/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the reel's rendering status, its media, its caption availability, and its live share links. This is the polling endpoint for every kind of reel — one created from clips, from a report question, or from a single emotion observation. Media URLs appear only once status is COMPLETED.

Path parameters
NameTypeDescription
reel_iduuidID of the reel.
Response

The reel, its media, and its share links. Every URL in this payload except `share_links[].share_url` is a short-lived presigned URL — fetch the bytes promptly and re-read the reel instead of storing them.

FieldTypeDescription
data.iduuidReel ID.
data.project_iduuidProject the reel belongs to.
data.titlestringName of the reel; empty for single-clip downloads, which render without a title slide.
data.typeenumHow the reel was built — `AI_PROJECT_HIGHLIGHT` (clip selection), `AI_C_HIGHLIGHT` (report question category), `REPORTQ_HIGHLIGHT` (report question), `OBS_HIGHLIGHT` (observation clip or emotion drill-down), `CODESIGN_HIGHLIGHT` (co-design edits), or `USER_GENERATED` (built by a researcher in the product).
data.statusenum`QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, or `NO_RESULTS` when no clip matched what was asked for.
data.clips_countintegerNumber of clips stitched into the reel.
data.total_duration_secondsfloatLength of the rendered reel in seconds, or the sum of its clips' durations while it is still rendering.
data.created_atdatetimeWhen the reel was created (ISO 8601, UTC).
data.video_preview_urlstringPresigned URL for the reel's poster frame, or `null` when no thumbnail has been rendered.
data.captions.media_urlstringPresigned URL for the rendered reel video, or `null` until it completes.
data.captions.has_captioned_clipbooleanWhether a second render with captions burned into the picture exists.
data.captions.captioned_media_urlstringPresigned URL for the burned-in captioned video, or `null` when there is none.
data.captions.has_captions_srtbooleanWhether a standalone SRT subtitle file was generated for the reel.
data.captions.captions_srt_urlstringPresigned URL for the SRT sidecar, or `null` when there is none.
data.captions.available_caption_languagesarray[string]Language codes every contributing study can caption in — the intersection, so a mixed-language reel offers only what all of its studies share.
data.captions.media_withheld_for_pii_reviewbooleanTrue when a source interview is under PII review; while it is, every media URL on the reel reads `null` even though the reel itself is `COMPLETED`.
data.captions.translated_renders[].language_codestringLanguage of this translated caption render.
data.captions.translated_renders[].statusenum`QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, or `STALE` when the reel was re-rendered and this language has not caught up.
data.captions.translated_renders[].media_urlstringPresigned URL for the translated render, or `null` until it completes.
data.share_links[].share_link_iduuidID of the share link, used to revoke it.
data.share_links[].share_urlstringDurable public URL that opens the reel without signing in.
data.share_links[].expires_atdatetimeWhen the link stops working, or `null` for a link that never expires.
data.share_links[].is_expiredbooleanWhether the link has already passed its expiry.
{
  "data": {
    "id": "7c31a0e8-5d64-4b92-8f07-1a6e3c9d4b25",
    "project_id": "2f8b41c9-0e76-4d3a-95b8-c70d51a6f284",
    "title": "Checkout friction — top moments",
    "type": "AI_PROJECT_HIGHLIGHT",
    "status": "COMPLETED",
    "clips_count": 2,
    "total_duration_seconds": 74.3,
    "created_at": "2026-08-11T09:14:22Z",
    "video_preview_url": "https://media.outset.ai/clips/…/7c31a0e8.jpg?X-Amz-Signature=…",
    "captions": {
      "media_url": "https://media.outset.ai/clips/…/7c31a0e8.mp4?X-Amz-Signature=…",
      "has_captioned_clip": true,
      "captioned_media_url": "https://media.outset.ai/clips/…/7c31a0e8_caption_en.mp4?X-Amz-Signature=…",
      "has_captions_srt": true,
      "captions_srt_url": "https://media.outset.ai/clips/…/7c31a0e8_captions_en.srt?X-Amz-Signature=…",
      "available_caption_languages": [
        "de",
        "en"
      ],
      "media_withheld_for_pii_review": false,
      "translated_renders": [
        {
          "language_code": "de",
          "status": "COMPLETED",
          "media_url": "https://media.outset.ai/clips/…/7c31a0e8_de.mp4?X-Amz-Signature=…"
        }
      ]
    },
    "share_links": [
      {
        "share_link_id": "e0a7d914-6c53-4b81-9f26-73b5a0e8c142",
        "share_url": "https://app.outset.ai/shared/reel/7c31a0e8-5d64-4b92-8f07-1a6e3c9d4b25/e0a7d914-6c53-4b81-9f26-73b5a0e8c142",
        "expires_at": "2026-09-30T00:00:00Z",
        "is_expired": false
      }
    ]
  }
}

Captions are not a setting: an SRT sidecar and, where applicable, a burned-in render are produced automatically after the reel finishes, so a reel can read COMPLETED a short while before its caption fields fill in. Expired share links are omitted from share_links rather than listed as dead.

List a project's highlight reels #
GET /v2/projects/{project_id}/highlight-reels/ proposed
Scope analytics:read Credentials: user-delegated, partner

Lists the project's reels, newest first, including those researchers built in the product. Rows carry the same fields as the read endpoint, so this is how you discover reel IDs you did not create yourself.

Path parameters
NameTypeDescription
project_iduuidProject whose reels to list.
Query parameters
NameTypeDescription
status enum Return only reels in this state — `QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, or `NO_RESULTS`.
page_size integer · default 50 Rows per page, up to 200.
cursor string Opaque cursor from the previous page's `next_cursor`.
Response

A cursor-paginated page of reels, newest first. Each element of `data[]` is a full reel object — its fields are listed below with the single-object `data.` prefix the read endpoint uses, and every URL in a row is presigned the same way.

FieldTypeDescription
data.iduuidReel ID.
data.project_iduuidProject the reel belongs to.
data.titlestringName of the reel; empty for single-clip downloads, which render without a title slide.
data.typeenumHow the reel was built — `AI_PROJECT_HIGHLIGHT` (clip selection), `AI_C_HIGHLIGHT` (report question category), `REPORTQ_HIGHLIGHT` (report question), `OBS_HIGHLIGHT` (observation clip or emotion drill-down), `CODESIGN_HIGHLIGHT` (co-design edits), or `USER_GENERATED` (built by a researcher in the product).
data.statusenum`QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, or `NO_RESULTS` when no clip matched what was asked for.
data.clips_countintegerNumber of clips stitched into the reel.
data.total_duration_secondsfloatLength of the rendered reel in seconds, or the sum of its clips' durations while it is still rendering.
data.created_atdatetimeWhen the reel was created (ISO 8601, UTC).
data.video_preview_urlstringPresigned URL for the reel's poster frame, or `null` when no thumbnail has been rendered.
data.captions.media_urlstringPresigned URL for the rendered reel video, or `null` until it completes.
data.captions.has_captioned_clipbooleanWhether a second render with captions burned into the picture exists.
data.captions.captioned_media_urlstringPresigned URL for the burned-in captioned video, or `null` when there is none.
data.captions.has_captions_srtbooleanWhether a standalone SRT subtitle file was generated for the reel.
data.captions.captions_srt_urlstringPresigned URL for the SRT sidecar, or `null` when there is none.
data.captions.available_caption_languagesarray[string]Language codes every contributing study can caption in — the intersection, so a mixed-language reel offers only what all of its studies share.
data.captions.media_withheld_for_pii_reviewbooleanTrue when a source interview is under PII review; while it is, every media URL on the reel reads `null` even though the reel itself is `COMPLETED`.
data.captions.translated_renders[].language_codestringLanguage of this translated caption render.
data.captions.translated_renders[].statusenum`QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, or `STALE` when the reel was re-rendered and this language has not caught up.
data.captions.translated_renders[].media_urlstringPresigned URL for the translated render, or `null` until it completes.
data.share_links[].share_link_iduuidID of the share link, used to revoke it.
data.share_links[].share_urlstringDurable public URL that opens the reel without signing in.
data.share_links[].expires_atdatetimeWhen the link stops working, or `null` for a link that never expires.
data.share_links[].is_expiredbooleanWhether the link has already passed its expiry.
{
  "data": [
    {
      "id": "7c31a0e8-5d64-4b92-8f07-1a6e3c9d4b25",
      "project_id": "2f8b41c9-0e76-4d3a-95b8-c70d51a6f284",
      "title": "Checkout friction — top moments",
      "type": "AI_PROJECT_HIGHLIGHT",
      "status": "COMPLETED",
      "clips_count": 2,
      "total_duration_seconds": 74.3,
      "created_at": "2026-08-11T09:14:22Z",
      "…": "video_preview_url, captions, and share_links exactly as on the read endpoint"
    },
    "… one entry per reel, newest first"
  ],
  "next_cursor": "cD0yMDI2LTA4LTExVDA5OjE0OjIyWg",
  "has_more": true
}

Archived reels are omitted. Every row presigns its own media URLs, so keep page_size modest when you only need the IDs.

Create a reel from a report question #
POST /v2/reports/{report_id}/questions/{report_question_id}/highlight-reels/ proposed
Scope analytics:write Credentials: user-delegated, partner

Builds a reel from what a report already found for one question: pass category for every quote tagged with that category, or emotion for the clips behind one bar of the question's emotion breakdown. Returns 202 with a reel to poll. Exactly one of the two is required.

Path parameters
NameTypeDescription
report_iduuidReport the question belongs to.
report_question_iduuidReport question to build the reel from — the report question id carried by the report reads, not the id of a question in the study guide.
Request body

Which slice of the question to turn into a reel.

FieldTypeDescription
category string A category the report assigned to this question, spelled exactly as the report has it. Every quote in that category goes into the reel.
emotion enum One of `anger`, `contempt`, `disgust`, `fear`, `happiness`, `sadness`, `surprise`, `neutral` — collects the clips behind that emotion for this question.
emotion_scope enum Set to `codesign_reactions` to collect reactions to AI-generated images instead of the question's overall emotion drill-down. Only valid alongside `emotion`.
title string Name for the reel. Defaults to the category and the question's headline; ignored for emotion reels, which render without a title slide.
prompt_text string Optional instruction steering which quotes in the category are picked. Only valid alongside `category`.
{
  "category": "Unexpected cost",
  "title": "Unexpected cost — what people actually said"
}
Response

The reel, in the same shape as the read endpoint. Poll it at `GET /v2/highlight-reels/{reel_id}/`.

FieldTypeDescription
dataobjectThe reel, in the same shape `GET /v2/highlight-reels/{reel_id}/` returns: id, project_id, title, type, status, clips_count, created_at.
data.iduuidReel ID.
data.statusenum`QUEUED` for a fresh reel, or the current state of the reel that was reused.
data.typeenum`AI_C_HIGHLIGHT` for a category reel, `OBS_HIGHLIGHT` for an emotion reel.
{
  "data": {
    "id": "3a6c19d5-84f0-4e27-b913-5c8d2f7a06be",
    "project_id": "2f8b41c9-0e76-4d3a-95b8-c70d51a6f284",
    "title": "Unexpected cost — what people actually said",
    "type": "AI_C_HIGHLIGHT",
    "status": "QUEUED",
    "clips_count": 0,
    "created_at": "2026-08-11T09:31:47Z"
  }
}
Errors
StatusCodeWhen
400validation_errorNeither or both of `category` and `emotion` were given, `emotion_scope` or `prompt_text` was paired with the wrong one, or the category is not one this report question carries — categories change when a report is regenerated, so re-read the report's categories before retrying.

An emotion reel is deduplicated: asking again for the same question, emotion, and scope returns the reel that already exists — 202 while it renders, 200 once it is complete — so polling by re-posting is safe. A category reel is not deduplicated; each call renders another one. The reel is a snapshot of the report at build time and does not follow later report reruns.

Download a single emotion observation clip #
POST /v2/reports/{report_id}/emotion-observations/{observation_id}/highlight-reels/ proposed
Scope analytics:write Credentials: user-delegated, partner

Renders the moment behind one emotion observation as a standalone trimmed clip, for pulling a single reaction out of an interview. Returns 202 with a reel of one clip to poll.

Path parameters
NameTypeDescription
report_iduuidReport the observation belongs to.
observation_iduuidEmotion observation to clip, from the report's emotion observations.
Response

The one-clip reel, in the same shape as the read endpoint.

FieldTypeDescription
dataobjectThe reel, in the same shape `GET /v2/highlight-reels/{reel_id}/` returns: id, project_id, title, type, status, clips_count, created_at.
data.iduuidReel ID; poll it at `GET /v2/highlight-reels/{reel_id}/`.
data.statusenum`QUEUED` for a fresh render, or the current state of the clip that was reused.
{
  "data": {
    "id": "c95e2b47-71da-4038-8e6f-04a3b1c7d259",
    "project_id": "2f8b41c9-0e76-4d3a-95b8-c70d51a6f284",
    "title": "",
    "type": "OBS_HIGHLIGHT",
    "status": "QUEUED",
    "clips_count": 1,
    "created_at": "2026-08-11T09:42:03Z"
  }
}

Deduplicated per observation: repeating the request returns the existing clip — 202 while it renders, 200 once complete — so it doubles as a safe poll. The clip renders raw, with no title slide or overlays.

Chapter 15

Exports & Data Delivery #

This is the "get everything out" surface: bulk answer, transcript, report, and image files, plus direct access to the recordings behind them. Anything that renders a file is asynchronous — you create an export job, poll it, and download the finished artifact from a short-lived presigned URL; anything already sitting in storage (a stitched interview recording, a single message's audio, aggregated screener counts) is a direct read. Every artifact type here is produced by Outset in production today, in the formats it already produces — CSV, JSON, ZIP, DOCX, and PPTX; there is no XLSX anywhere. The export-job API presents them behind one create-poll-download contract, so a partner writes the polling loop once. All of it reads participant output, so all of it needs analytics:read; the study-instrument export is study design and needs studies:read instead. PII gating applies below the API on every route here: interviews whose PII scan has not completed are omitted from exports entirely, messages under review render as [PII Under Review], and redacted content never leaves the platform.

Export a study's answers or transcripts #
POST /v2/studies/{study_id}/exports/ proposed
Scope analytics:read Credentials: user-delegated, partner

Starts an export job over one study's interviews and returns 202 with the job to poll. The same filter, language, and translation options apply to every artifact type, so a CSV and a transcript ZIP of the same cohort are requested identically.

Path parameters
NameTypeDescription
study_iduuidThe study whose interviews to export.
Request body

The artifact to render, plus the cohort and language to render it for.

FieldTypeDescription
artifact required string What to render: `answers-csv` (one row per interview, one column group per question), `answers-json` (the same data as JSON), or `transcripts-zip` (one transcript file per interview, screener answers included inline).
language string Render participant text in this language, translating on demand where a translation does not exist yet; must be a ready (generated or reviewed) target language of the study, or its source language.
translated boolean Render participant text in the language the study was written in rather than the language each interview was conducted in; ignored when `language` is set. Defaults to false.
filters.only_completed boolean Include only interviews that finished — no fraud-flagged, archived, low-quality, or still-running sessions. Defaults to false.
filters.screened_out boolean Include only interviews that were screened out. Defaults to false.
filters.exclude_low_quality boolean Drop interviews Outset flagged as low quality. Defaults to false.
filters.exclude_test_interviews boolean Drop test and synthetic interviews, unless the study has nothing else — a study still in its pre-launch test phase exports its test interviews regardless. Defaults to false.
filters.include_archived boolean Include archived interviews, which are otherwise excluded. Defaults to false.
filters.interview_ids array Restrict the export to these interviews; omit to export every interview the other filters allow.
filters.metadata array Match interviews on the metadata you passed in at recruitment; each entry is `{"key": "", "values": ["", …]}`, matching any listed value for that key and requiring every entry to match.
{
  "artifact": "answers-csv",
  "language": "de",
  "filters": {
    "only_completed": true,
    "exclude_low_quality": true,
    "metadata": [
      {
        "key": "market",
        "values": [
          "DE",
          "AT"
        ]
      }
    ]
  }
}
Response

The export job, in the same shape the poll endpoint returns.

FieldTypeDescription
data.iduuidExport job identifier — poll `GET /v2/exports/{export_id}/` with it.
data.statusstringJob state: `QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`.
data.artifactstringThe artifact type this job renders, echoed from the request.
data.study_iduuidThe study the export covers.
data.requested_atdatetimeWhen the job was created (ISO 8601, UTC).
data.interview_countintegerHow many interviews matched the filters at kickoff, or null until the job has counted them.
{
  "data": {
    "id": "8f3c1d92-5b47-4e0a-9c26-71ad4f8e2b13",
    "status": "QUEUED",
    "artifact": "answers-csv",
    "study_id": "2b7e5a10-9f34-4c88-b1d6-0e5c3a97f421",
    "requested_at": "2026-08-11T09:12:44Z",
    "interview_count": 318
  }
}
Errors
StatusCodeWhen
400unsupported_artifactThe `artifact` is not one this endpoint renders — report-scoped artifacts go to `POST /v2/reports/{report_id}/exports/`.
400language_not_availableThe requested `language` is not a ready target language of this study, or its source language.
403pii_export_blockedThe study's PII review is still open and the organization's policy blocks bulk export until it clears.
503translation_unavailableAn on-demand translation into `language` could not be produced right now; the job is not created, so retry.

Filters are applied at kickoff, so a job started while the study is still fielding captures the cohort as of that moment — re-run the export to pick up later interviews. Two cohort rules are not negotiable through filters: interviews whose PII detection has not completed are always withheld, and the answers artifacts always exclude test interviews. In the product an export like this also emails the requester a link; that email is a convenience, not part of this API's contract — poll the job.

Export a report #
POST /v2/reports/{report_id}/exports/ proposed
Scope analytics:read Credentials: user-delegated, partner

Starts an export job over one report — the analyzed, point-in-time snapshot of a study's answers — and returns 202 with the job to poll. A report can span several studies in a project, which is why report exports are addressed by report rather than by study.

Path parameters
NameTypeDescription
report_iduuidThe report to export.
Request body

The artifact to render, and for image exports which questions to pull images from.

FieldTypeDescription
artifact required string What to render: `report-csv` (one row per interview, a quotes/categories/summary column group per question), `report-json` (the same data as JSON, plus concept groups and comparison matrices when the report has them), `report-slides-pptx` (an auto-generated slide deck of the report), or `images-zip` (participant-uploaded images, foldered by question).
selections array For `images-zip` only: which questions to include, each `{"question_id": "", "variants": ["uploads"]}`; omit to be rejected rather than silently exporting everything.
{
  "artifact": "images-zip",
  "selections": [
    {
      "question_id": "d41a7c05-3e62-4b19-8f7a-2c9de6031b8f",
      "variants": [
        "uploads"
      ]
    }
  ]
}
Response

The export job, in the same shape the poll endpoint returns.

FieldTypeDescription
data.iduuidExport job identifier — poll `GET /v2/exports/{export_id}/` with it.
data.statusstringJob state: `QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`.
data.artifactstringThe artifact type this job renders, echoed from the request.
data.report_iduuidThe report the export covers.
data.requested_atdatetimeWhen the job was created (ISO 8601, UTC).
{
  "data": {
    "id": "c6b0a5f7-2d18-4a93-8e51-4f7bc09d6a35",
    "status": "QUEUED",
    "artifact": "images-zip",
    "report_id": "5e9d2c84-71b3-4f0e-a6c9-38b1d47e0a52",
    "requested_at": "2026-08-11T09:20:07Z"
  }
}
Errors
StatusCodeWhen
400unsupported_artifactThe `artifact` is not one this endpoint renders — study-scoped artifacts go to `POST /v2/studies/{study_id}/exports/`.
400validation_error`images-zip` was requested without `selections`, or a selected question has no downloadable images.
404not_foundThe report does not exist, or is outside the credential's workspaces.

A report is an immutable snapshot: exporting one twice returns the same content, and interviews that completed after the report ran are not in it. To capture newer interviews, re-run the report first (Analysis & Insights chapter) and export the new one. images-zip covers participant uploads; co-design (AI image-edit) journeys are exported as a Word document from the co-design surface, not from here.

Export several studies as one bundle #
POST /v2/exports/ proposed
Scope analytics:read Credentials: user-delegated, partner

Starts one export job across a list of studies and returns 202 with the job to poll. The result is a single ZIP containing a per-study folder — the shape to use for a periodic "pull everything since last time" sync rather than one job per study.

Request body

The studies to bundle and what to include for each of them.

FieldTypeDescription
study_ids required array The studies to include; every one must be reachable by the credential, and at least one is required.
include_answers boolean Include each study's answers file in its folder; at least one of `include_answers` and `include_transcripts` must be true.
include_transcripts boolean Include each study's per-interview transcripts in its folder.
only_completed boolean Restrict every study in the bundle to interviews that finished. Defaults to false.
language string Render participant text in this language for every study that offers it; a study that does not is exported in its own language rather than failing the bundle.
translated boolean Render participant text in each study's own written language rather than the language its interviews were conducted in; ignored when `language` is set. Defaults to false.
{
  "study_ids": [
    "2b7e5a10-9f34-4c88-b1d6-0e5c3a97f421",
    "9c40e1b6-8d75-4a23-91f0-6b2e5d8c73a4"
  ],
  "include_answers": true,
  "include_transcripts": true,
  "only_completed": true
}
Response

The export job, in the same shape the poll endpoint returns.

FieldTypeDescription
data.iduuidExport job identifier — poll `GET /v2/exports/{export_id}/` with it.
data.statusstringJob state: `QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`.
data.artifactstring`studies-bundle-zip` for every job created here.
data.study_idsarrayThe studies the bundle covers, in the order they were requested.
data.requested_atdatetimeWhen the job was created (ISO 8601, UTC).
{
  "data": {
    "id": "1a6f93c7-84be-4d02-b5e7-c93f21806de4",
    "status": "QUEUED",
    "artifact": "studies-bundle-zip",
    "study_ids": [
      "2b7e5a10-9f34-4c88-b1d6-0e5c3a97f421",
      "9c40e1b6-8d75-4a23-91f0-6b2e5d8c73a4"
    ],
    "requested_at": "2026-08-11T09:31:58Z"
  }
}
Errors
StatusCodeWhen
400validation_error`study_ids` is empty, or neither `include_answers` nor `include_transcripts` is true.
404not_foundAt least one of the studies does not exist or is outside the credential's workspaces; the response names the ids it could not resolve.

The bundle is all-or-nothing on access — if any study in the list is unreachable, no job is created — but not on content: a study with no matching interviews still gets a folder, and a study that cannot serve the requested language falls back to its own language instead of failing the run. Bundles are the expensive shape here; a nightly sync of a large estate should page through studies rather than requesting hundreds in one job.

Poll an export job #
GET /v2/exports/{export_id}/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns an export job's current state, and once it reaches COMPLETED, a short-lived presigned URL to download the finished file. Poll every few seconds; large studies take minutes.

Path parameters
NameTypeDescription
export_iduuidThe export job to read, as returned by any of the create endpoints.
Response

The export job as a single object.

FieldTypeDescription
data.iduuidExport job identifier.
data.statusstringJob state: `QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`. Only the last two are terminal.
data.artifactstringThe artifact type this job renders.
data.study_iduuidThe study the export covers, or null for report and bundle exports.
data.report_iduuidThe report the export covers, or null for study and bundle exports.
data.study_idsarrayThe studies a bundle export covers; empty for single-study and report exports.
data.requested_atdatetimeWhen the job was created (ISO 8601, UTC).
data.completed_atdatetimeWhen the job reached a terminal state, or null while it is still running.
data.file_namestringSuggested file name for the artifact, or null before it exists.
data.content_typestringMIME type of the artifact — `text/csv`, `application/json`, `application/zip`, or the Office type for a slide deck; null before it exists.
data.byte_sizeintegerSize of the finished artifact in bytes, or null before it exists.
data.download_urlstringPresigned download URL, present only while the job is `COMPLETED` and the URL has not expired. Fetch it immediately and never store it.
data.download_url_expires_atdatetimeWhen the presigned URL stops working; re-read this endpoint to get a fresh one.
data.error.codestringMachine-readable failure reason on a `FAILED` job, e.g. `translation_unavailable` or `render_failed`; null otherwise.
data.error.detailstringHuman-readable failure description on a `FAILED` job; null otherwise.
{
  "data": {
    "id": "8f3c1d92-5b47-4e0a-9c26-71ad4f8e2b13",
    "status": "COMPLETED",
    "artifact": "answers-csv",
    "study_id": "2b7e5a10-9f34-4c88-b1d6-0e5c3a97f421",
    "report_id": null,
    "study_ids": [],
    "requested_at": "2026-08-11T09:12:44Z",
    "completed_at": "2026-08-11T09:14:02Z",
    "file_name": "brand-tracker-wave-3-answers-de.csv",
    "content_type": "text/csv",
    "byte_size": 2841077,
    "download_url": "https://exports.outset.ai/exports/8f3c1d92…?X-Amz-Expires=900&X-Amz-Signature=…",
    "download_url_expires_at": "2026-08-11T09:29:31Z",
    "error": {
      "code": null,
      "detail": null
    }
  }
}
Errors
StatusCodeWhen
404not_foundNo such job, the job belongs to a study or report outside the credential's workspaces, or the finished artifact has passed its retention window and been deleted.

Finished artifacts are retained for a limited period and then deleted — a job id is not an archive, so download the file and keep your own copy. The presigned URL expires within minutes and is bound to the artifact, not to your credential: treat it as a secret, do not forward it, and re-read this endpoint if it lapses. A FAILED job is never retried automatically; create a new one.

Get an interview's full-session recording #
GET /v2/interviews/{interview_id}/recording/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns a presigned URL for the stitched MP4 of a whole interview session — every recorded turn in one file, as opposed to the per-message audio below. Not every interview has one: it is rendered on demand, so a NOT_GENERATED interview needs the POST on the same path first.

Path parameters
NameTypeDescription
interview_iduuidThe interview whose full-session recording to read.
Response

The recording's availability and, when it is ready, where to fetch it.

FieldTypeDescription
data.statusstring`READY` (downloadable), `RUNNING` (a render is in flight), `NOT_GENERATED` (none exists yet), or `WITHHELD` (it exists but the interview's PII review has not cleared). This describes artifact availability, not an async job.
data.download_urlstringPresigned URL for the stitched MP4, present only when the status is `READY`; expires within minutes and must not be persisted.
data.download_url_expires_atdatetimeWhen the presigned URL stops working, or null when there is no URL.
data.duration_secondsfloatLength of the stitched recording in seconds, or null before it is rendered.
{
  "data": {
    "status": "READY",
    "download_url": "https://media.outset.ai/media-transcripts/7d2e…/full_media_transcript.mp4?X-Amz-Expires=900&X-Amz-Signature=…",
    "download_url_expires_at": "2026-08-11T09:47:10Z",
    "duration_seconds": 1483.2
  }
}
Errors
StatusCodeWhen
404not_foundThe interview does not exist, is outside the credential's workspaces, or its PII detection has not completed — an unscanned interview is withheld rather than returned.

WITHHELD and 404 mean different things and both are normal: an interview awaiting PII review exists and is listable but its media is held back (WITHHELD), while an interview whose PII scan has not run at all is not returned by any read endpoint (404). Recordings are not redactable per message, so the whole file stays withheld until review clears — poll rather than treating it as a permanent failure.

Render an interview's full-session recording #
POST /v2/interviews/{interview_id}/recording/ proposed
Scope analytics:read Credentials: user-delegated, partner

Requests the stitched full-session MP4 for an interview that does not have one yet and returns 202. Stitching runs on the media pipeline, so poll the GET on the same path until it reads READY.

Path parameters
NameTypeDescription
interview_iduuidThe interview to render a full-session recording for.
Response

The render request that was accepted.

FieldTypeDescription
data.statusstring`RUNNING` when a render was started, or `READY` when one already existed and nothing was queued.
{
  "data": {
    "status": "RUNNING"
  }
}
Errors
StatusCodeWhen
404not_foundThe interview does not exist, is outside the credential's workspaces, or its PII detection has not completed.
409no_recorded_mediaThe interview has no recorded turns to stitch — a text-only interview never gets a session recording.

Calling this for an interview that already has a recording, or one whose render is in flight, is a no-op rather than a second render — it is safe to call unconditionally before polling. Rendering is billable machine time on long video interviews, so do not use repeated POSTs as a polling mechanism.

Download one message's recording #
GET /v2/interviews/{interview_id}/messages/{message_id}/recording/ proposed
Scope analytics:read Credentials: user-delegated, partner

Redirects to a presigned URL for the raw audio of a single participant answer. Point a downloader at it directly — following redirects is all a client needs to do.

Path parameters
NameTypeDescription
interview_iduuidThe interview the message belongs to.
message_iduuidThe message whose recording to download.
Response

302 redirect — the Location header carries a short-lived presigned URL for the raw recording.

Errors
StatusCodeWhen
404not_foundThe message has no recording, the interview is outside the credential's workspaces, or the recording is withheld — because the message is redacted, its PII flag is unresolved, or the project hides message recordings.

The Location URL expires within minutes and must not be persisted — re-request this endpoint instead of storing it. This is per-message audio, not the session video: use GET /v2/interviews/{interview_id}/recording/ for one file covering the whole interview. The interview detail endpoint (Interviews chapter) can inline these URLs for a whole transcript in one call with include_recordings=true, which is cheaper than a redirect per message.

Request captioned recordings for an interview #
POST /v2/interviews/{interview_id}/captions/ proposed
Scope analytics:read Credentials: user-delegated, partner

Requests burned-in captions, in one language, for every recorded answer in an interview, and returns 202. Burning runs on the media pipeline; read the per-message endpoint below to see which recordings are ready.

Path parameters
NameTypeDescription
interview_iduuidThe interview to caption.
Request body

The caption language.

FieldTypeDescription
language required string Language to caption in; must be a ready target language of the study, or its source language.
{
  "language": "fr"
}
Response

What the request queued.

FieldTypeDescription
data.languagestringThe language the captions are being produced in.
data.requested_countintegerHow many of the interview's recordings a caption burn was queued for.
data.ready_countintegerHow many already had captions in this language and were left alone.
{
  "data": {
    "language": "fr",
    "requested_count": 14,
    "ready_count": 3
  }
}
Errors
StatusCodeWhen
400language_not_availableThe language is not one this study offers for download.
403feature_not_enabledPer-language captions are not enabled for the organization.
409translation_not_currentThe interview's transcript has no current translation in that language yet — read the transcript in that language first, then retry.

Captions embed the spoken text, so they inherit the transcript's privacy gates exactly: a message that is redacted or under PII review is skipped, and its caption artifacts stay withheld even after the rest of the interview finishes burning. Each language is burned once per recording and cached, so re-requesting an already-captioned language costs nothing.

List a message's captioned recordings #
GET /v2/interviews/{interview_id}/messages/{message_id}/captions/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns the caption languages this study can produce, plus the per-language state of this message's captioned video and a presigned URL for each finished burn. Poll it while a language reads RUNNING.

Path parameters
NameTypeDescription
interview_iduuidThe interview the message belongs to.
message_iduuidThe message whose captioned recordings to list.
Response

Available caption languages and this message's burns. Returned whole — the set is bounded by the study's caption languages, not paginated.

FieldTypeDescription
data.available_languagesarrayLanguage codes this study can caption in — its ready target languages plus its source language.
data.captions[].languagestringThe language of this burn.
data.captions[].statusstring`QUEUED`, `RUNNING`, `COMPLETED`, or `FAILED`.
data.captions[].download_urlstringPresigned URL for the captioned MP4, present only on a `COMPLETED` burn whose media is not withheld for privacy review.
data.captions[].download_url_expires_atdatetimeWhen the presigned URL stops working, or null when there is no URL.
{
  "data": {
    "available_languages": [
      "en",
      "fr",
      "ja"
    ],
    "captions": [
      {
        "language": "fr",
        "status": "COMPLETED",
        "download_url": "https://media.outset.ai/captions/3f9b…-fr.mp4?X-Amz-Expires=900&X-Amz-Signature=…",
        "download_url_expires_at": "2026-08-11T10:02:55Z"
      },
      {
        "language": "ja",
        "status": "RUNNING",
        "download_url": null,
        "download_url_expires_at": null
      }
    ]
  }
}
Errors
StatusCodeWhen
403feature_not_enabledPer-language captions are not enabled for the organization.
404not_foundThe message does not exist in that interview, or the interview is outside the credential's workspaces.

A message with no captionable recording — no audio, redacted, or PII-unresolved — returns an empty captions list rather than an error, so an empty list is not a failure signal.

Request a captioned recording for one message #
POST /v2/interviews/{interview_id}/messages/{message_id}/captions/ proposed
Scope analytics:read Credentials: user-delegated, partner

Requests a burned-in captioned video of a single answer in one language and returns 202. This is the "key quote" download — use it when you want one clip rather than captioning a whole interview.

Path parameters
NameTypeDescription
interview_iduuidThe interview the message belongs to.
message_iduuidThe message to caption.
Request body

The caption language.

FieldTypeDescription
language required string Language to caption in; must be a ready target language of the study, or its source language.
{
  "language": "fr"
}
Response

The burn that was queued, in the same row shape the GET returns.

FieldTypeDescription
data.languagestringThe language of the burn.
data.statusstring`QUEUED` or `RUNNING` on a fresh request, `COMPLETED` when the burn already existed.
{
  "data": {
    "language": "fr",
    "status": "QUEUED"
  }
}
Errors
StatusCodeWhen
400language_not_availableThe language is not one this study offers for download.
403feature_not_enabledPer-language captions are not enabled for the organization.
404not_foundThe message has no captionable recording, or the interview is outside the credential's workspaces.
Get aggregated screener results #
GET /v2/studies/{study_id}/screener-results/ proposed
Scope analytics:read Credentials: user-delegated, partner

Returns per-question option-selection counts across everyone who answered the study's screener — the aggregate view, not per-participant rows. Use it for incidence and quota reporting without exporting a file.

Path parameters
NameTypeDescription
study_iduuidThe study whose screener results to read.
Response

One entry per screener question, in the screener's own question order.

FieldTypeDescription
data[].question_iduuidThe screener question.
data[].question_textstringThe question as participants saw it, in the study's source language.
data[].question_typestringThe screener question's type, e.g. `MULTIPLE_CHOICE` or `MULTIPLE_SELECT`.
data[].total_responsesintegerHow many respondents answered this question.
data[].options[].option_textstringThe answer option's display text.
data[].options[].countintegerHow many respondents picked this option; options nobody picked are included with a count of zero.
{
  "data": [
    {
      "question_id": "a17c4e63-92d5-4f10-b8e7-5d3f0a2c6b91",
      "question_text": "How often do you buy coffee outside the home?",
      "question_type": "MULTIPLE_CHOICE",
      "total_responses": 412,
      "options": [
        {
          "option_text": "Daily",
          "count": 148
        },
        {
          "option_text": "A few times a week",
          "count": 173
        },
        {
          "option_text": "Rarely or never",
          "count": 91
        }
      ]
    }
  ]
}
Errors
StatusCodeWhen
404not_foundThe study does not exist, is outside the credential's workspaces, or has no screener.

Counts include respondents who were later flagged as fraudulent or low quality — screening happened before those judgements, and excluding them here would disagree with the screen-out counts elsewhere in the product. An option the researcher has since removed still appears if participants picked it, so historical counts never silently drop. For per-participant screener answers, use the interview detail endpoint or a transcript export, both of which carry screener Q&A inline.

Export the study instrument as a document #
GET /v2/studies/{study_id}/guide-document/ proposed
Scope studies:read Credentials: user-delegated, partner

Renders the study's discussion guide and/or screener as a Word document and returns a presigned URL to download it. This is study design, not participant output — the shape to archive alongside fieldwork, or to hand to a client for sign-off.

Path parameters
NameTypeDescription
study_iduuidThe study whose instrument to export.
Query parameters
NameTypeDescription
section string Which part to render: `guide` or `screener`; omit for both.
language string Repeatable. Render each item's translation in this language beneath the source text; each value must be a ready (generated or reviewed) target language of the study. Omit for the source language only.
Response

Where to download the rendered document.

FieldTypeDescription
data.download_urlstringPresigned URL for the `.docx` file; expires within minutes and must not be persisted.
data.download_url_expires_atdatetimeWhen the presigned URL stops working.
data.file_namestringSuggested file name for the document.
data.content_typestringAlways `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
{
  "data": {
    "download_url": "https://exports.outset.ai/study-docs/2b7e5a10….docx?X-Amz-Expires=900&X-Amz-Signature=…",
    "download_url_expires_at": "2026-08-11T09:52:18Z",
    "file_name": "brand-tracker-wave-3-guide.docx",
    "content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  }
}
Errors
StatusCodeWhen
400validation_error`section` is neither `guide` nor `screener`, or a requested `language` is not a ready target language of this study.
404not_foundThe study does not exist, or is outside the credential's workspaces.

The study's source language is always the base text and cannot be requested as a translation target. The document reflects the study as it stands right now, including unpublished edits — it is not a snapshot of what any particular participant saw.

Chapter 16

Webhooks #

Webhooks let Outset push study and organization events to your backend instead of you polling for them. Delivery is existing and already live for whitelabel partners; the event catalog and a self-service subscription API are planned.

How delivery works today

Webhook delivery is configured per partner integration, not per organization — every client organization provisioned under your provider shares the same set of destination endpoints. An integration carries a list of endpoint URLs and a parallel list of header dictionaries, one per URL, sent verbatim on every request (this is how a partner supplies its own bearer token or API key — Outset does not generate or manage that credential).

On a triggering event, Outset does a synchronous POST of a JSON body to every configured URL, with a 20-second timeout. Delivery is synchronous with the triggering event — there is no queue and no redelivery: each endpoint gets exactly one delivery attempt per event, in whatever order the endpoints are listed. A non-2xx response or a network failure is logged and monitored on Outset's side but does not stop delivery to the remaining endpoints and does not raise back to the caller — falling back to some other channel on a rejected webhook isn't an option for organizations whose whole point is that Outset never contacts their users directly.

Event types

Type Status Carries
SEND_EMAIL existing The email type, recipient, organization id, study ids, and the template's rendered fields — sent whenever Outset would otherwise have emailed the organization's own users directly (study launched, study completed, transcript ready, export ready, etc.), plus estimated cost fields on the payloads that have them.
STUDY_UNPUBLISHED existing Organization id, study id, study name and URL, estimated current paid amount, and answered-question count.
FUNDS_REQUIRED planned Fires when a study launch is blocked by an organization's spend cap or wallet balance — carries the organization, the study, and the amount required to unblock it. This is the mechanism for a partner funding multiple client organizations from one central wallet to find out a client needs a top-up.
WORKSPACE_CREATED / WORKSPACE_ARCHIVED planned, reserved Reserved event names for when workspace write access lands on the platform API. Not fired today.

Two payload fields are also planned additions across every event type: an external organization id (the partner's own identifier for the org, rather than Outset's internal one) and a nullable workspace id. Today's payloads carry only Outset's organization id with no workspace reference.

If your integration validates the type field against a fixed allow-list, expect these new types to arrive once they ship — the addition is coordinated with existing webhook partners rather than sprung on a running integration, but a consumer that hard-rejects unrecognized types should be prepared to add them.

Proposed: self-service subscriptions

Today, adding or changing a webhook endpoint is a configuration change Outset makes for you. A proposed /v2/webhooks/ resource would let a partner manage its own subscriptions under a reserved webhooks:manage scope, replacing that manual step with routes it fully owns. This is a sketch, not a committed design — the routes below describe the shape under discussion, not a built API.

Method Route Purpose
GET /v2/webhooks/ List configured subscriptions (URL, event types, status)
POST /v2/webhooks/ Register a new subscription
PATCH /v2/webhooks/{id}/ Update a subscription's URL, headers, or event types
DELETE /v2/webhooks/{id}/ Remove a subscription

Delivery guarantees

Treat delivery as at-most-once, best-effort, not exactly-once or guaranteed. There is no retry today — a single non-2xx response or timeout means that event never arrives at that endpoint, only a log line on Outset's side records the attempt — and today's payloads carry no dedicated event id or delivery id to dedupe against, so a partner that needs stronger guarantees should build reconciliation against the underlying resource (poll the study or organization state) rather than relying on the webhook stream alone. An idempotency and delivery-security refactor (signed payloads, retries, event ids) is planned; until it ships, key any dedup logic you do have on the fields already present in a payload (organization id, study id, email type) rather than waiting on an id field that doesn't exist yet.