Developer & API Documentation
Create interview projects, build conversational surveys, and run analysis programmatically — from your own code or from an AI agent via the QualiTaTi MCP server. REST over HTTPS, JSON in and out.
Overview
The QualiTaTi API lets you automate qualitative research workflows: spin up an AI interviewer, design conversational surveys, and run theme analysis (inductive coding) and quality rating on completed interviews. Anything you can do for these tasks in the web app, you can do over HTTP.
Developers — call the REST endpoints from your code, a notebook, or a no-code tool.
AI agents — connect the QualiTaTi MCP server to Claude Desktop, Cursor, or any MCP client and drive QualiTaTi in natural language.
Base URL
https://starfish-app-73rfk.ondigitalocean.app
Authentication
Every request authenticates with a personal API key (keys start with qt_). Create one in your profile → API Keys. The full key is shown once at creation — store it somewhere safe. A key acts on your behalf and draws on your plan and quota, so treat it like a password; you can revoke it anytime.
Send the key in the X-API-Key header on every request:
curl https://starfish-app-73rfk.ondigitalocean.app/api/projects \
-H "X-API-Key: qt_your_key_here"A missing or invalid key returns 401 with { "detail": "Invalid or revoked API key" }. POST/PUT bodies are JSON — send Content-Type: application/json.
Typical workflow
AI interview → analysis
Create a project —
POST /api/projects. The response has ashare_url(the participant link) and aproject_uuid.Share it — send the
share_urlto a participant, who completes the interview in their browser.Analyze — once an interview is finished, call
POST /api/analysis/interview/{uuid}/complete(quality + coding).Read results — fetch them anytime with
GET /api/analysis/interview/{uuid}/results.
Survey
Create a survey project —
POST /api/projectswithproject_type: "survey"so it appears in your Surveys dashboard.POST /api/surveys(withproject_id+title) → add questions withPOST /api/surveys/{id}/questions.POST /api/surveys/{id}/publishreturns ashare_tokenand a ready-to-shareshare_url. Send respondents theshare_url— the canonical form ishttps://qualitati.com/s/{survey_id}?t={share_token}(the link needs both the survey id and the token).Manage with the same key — read it back with
GET /api/surveys/{id}, edit/delete questions,POST /api/surveys/{id}/unpublish, orDELETE /api/surveys/{id}.
Quickstart
1. Create an AI-interview project
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/projects \
-H "X-API-Key: $QUALITATI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Onboarding interviews",
"outline": "Explore where new users get stuck during onboarding.",
"interview_type": "SEMI_STRUCTURED",
"interview_language": "ENGLISH",
"interaction_mode": "VOICE_TO_VOICE",
"max_duration": 30
}'Response (key fields):
{
"id": 1234,
"project_uuid": "a1b2c3d4-…",
"share_url": "9f8e7d6c-…", // the participant interview link/identifier
"name": "Onboarding interviews",
"interview_type": "SEMI_STRUCTURED",
"interview_language": "ENGLISH",
"interaction_mode": "VOICE_TO_VOICE"
}2. Analyze a completed interview
Run quality rating + inductive coding by the interview's UUID:
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/analysis/interview/$INTERVIEW_UUID/complete \
-H "X-API-Key: $QUALITATI_API_KEY"Response (shape):
{
"status": "completed",
"message": "...",
"data": {
"quality_analysis": {
"quality_score": 82,
"quality_rating": "High",
"core_takeaway": "..."
},
"coding_analysis": { "themes": [ /* ... */ ] }
}
}3. Build & publish a survey
# create a SURVEY project so the survey shows up in your Surveys dashboard
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/projects \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Post-trial study",
"outline": "Measure satisfaction after the free trial.",
"interview_type": "STRUCTURED",
"interview_language": "ENGLISH",
"project_type": "survey"
}'
# → { "id": 1234, ... } # use this as project_id below
# create a survey in that project
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{ "project_id": 1234, "title": "Post-trial feedback" }'
# add a question (returns the created question)
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/questions \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{
"type": "rating_scale",
"prompt": "How likely are you to recommend us?",
"variable_name": "nps",
"required": true,
"ai_follow_up_intensity": "light"
}'
# publish — returns the token AND a ready-to-share link:
# { "survey_id": 99, "status": "published",
# "share_token": "abc123…",
# "share_url": "https://qualitati.com/s/99?t=abc123…" }
# Send respondents the share_url (it carries BOTH survey id and token).
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/publish \
-H "X-API-Key: $QUALITATI_API_KEY"Matrix questions
A matrix question is a grid: rows are the statements/items down the side, columns are the shared scale points across the top. Send them as the documented rows and columns arrays — both are returned when you read the question back. (Unknown variants like rows_json or statements_json are rejected with 422 rather than silently dropped.)
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/questions \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{
"type": "matrix",
"prompt": "Rate each of the following",
"variable_name": "satisfaction_grid",
"rows": ["Ease of use", "Value for money", "Support quality"],
"columns": ["Poor", "Fair", "Good", "Excellent"]
}'
# GET /api/surveys/{id} returns each matrix question with rows + columns intact.Image stimuli: upload an image, then attach the returned URL to any question as prompt_image_url:
# upload → returns { "url": "/api/surveys/images/<uuid>" }
IMG_URL=$(curl -s -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/upload-image \
-H "X-API-Key: $QUALITATI_API_KEY" -F "[email protected]" | jq -r .url)
# attach it to a question
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/questions \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d "{ \"type\": \"single_choice\", \"prompt\": \"Which do you prefer?\",
\"variable_name\": \"pref\", \"prompt_image_url\": \"$IMG_URL\",
\"options_json\": [\"A\", \"B\"] }"Respondent flow (public, no API key)
Respondents are anonymous — these endpoints take the share token, not an API key. The canonical token field is share_token (in the JSON body); the aliases t / token (body) and ?t= / ?share_token= (query) are also accepted. A short /s/{token} link can be expanded via GET /api/public/surveys/by-token/{token}.
# 1. start a session (canonical: body share_token)
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/public/surveys/$SURVEY_ID/sessions \
-H "Content-Type: application/json" \
-d '{ "share_token": "abc123…" }'
# → { "session_uuid": "…", "participant_token": "…", "survey": { … } }
# 2. read the session / survey definition
curl "https://starfish-app-73rfk.ondigitalocean.app/api/public/survey-sessions/$SESSION_UUID?pt=$PARTICIPANT_TOKEN"
# 3. save answers (bulk)
curl -X PUT "https://starfish-app-73rfk.ondigitalocean.app/api/public/survey-sessions/$SESSION_UUID/answers?pt=$PARTICIPANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "answers": [ { "question_id": 1, "value_json": "Good", "value_text": "Good" } ] }'
# 4. submit
curl -X POST "https://starfish-app-73rfk.ondigitalocean.app/api/public/survey-sessions/$SESSION_UUID/submit?pt=$PARTICIPANT_TOKEN"Between-subjects (A/B) designs
To randomly assign each respondent to one of several conditions (e.g. scenario A vs. scenario B), use blocks. A randomizer block shows each respondent a random subset of its child blocks; each child block holds one condition's questions. It is a 3-step recipe:
Create the randomizer parent:
POST /blockswithis_randomizer: trueandrandomizer_pick_count: 1(one condition per respondent).Create one child block per condition:
POST /blockswithparent_block_idset to the randomizer's id.Move each condition's questions into its child block:
PUT /questions/{question_id}/movewith that block'sblock_id. Shared measures (asked of everyone) stay outside the randomizer — leave them unassigned.
# 1. randomizer parent (one condition per respondent)
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/blocks \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{ "title": "Scenario randomizer", "is_randomizer": true, "randomizer_pick_count": 1 }'
# → { "id": 50, ... } # the randomizer's block id
# 2. one child block per condition (parent_block_id = randomizer id)
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/blocks \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{ "title": "Condition A", "parent_block_id": 50 }' # → { "id": 51 }
curl -X POST https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/blocks \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{ "title": "Condition B", "parent_block_id": 50 }' # → { "id": 52 }
# 3. move each condition's question into its child block
curl -X PUT https://starfish-app-73rfk.ondigitalocean.app/api/surveys/$SURVEY_ID/questions/$QUESTION_A_ID/move \
-H "X-API-Key: $QUALITATI_API_KEY" -H "Content-Type: application/json" \
-d '{ "block_id": 51 }'Questions live in the child blocks, never directly in a randomizer. Call GET /api/surveys/{id} to read the current block tree (each block lists its question_ids and nested children) before moving questions. When you run synthetic-test on a randomized survey, each synthetic respondent is assigned to exactly one condition and only sees/answers that branch — mirroring a real respondent.
Endpoint reference
These endpoints accept an API key (they also accept a normal logged-in session, which is what the web app uses). Paths are relative to the base URL above.
Projects (AI interviews)
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/projects | name, outline, interview_type, interview_language, project_type? | Create a project. project_type is "interview" (default) or "survey" |
GET | /api/projects | project_type? (query) | List your projects. ?project_type=survey returns every project that owns ≥1 survey |
POST | /api/projects/{id}/share | — | Get (or mint) the participant share link |
GET | /api/projects/uuid/{project_uuid}/interviews | — | List interviews: status, duration, and external_participant_id (the id your page passed to embed.js) |
Surveys
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/surveys | project_id, title | Create a survey inside a project |
GET | /api/surveys?project_id={id} | — | List surveys in a project |
GET | /api/surveys/{id} | — | Fetch a survey with its questions + full block tree |
PUT | /api/surveys/{id} | title, description, … | Update survey settings |
DELETE | /api/surveys/{id} | — | Delete a survey |
POST | /api/surveys/{id}/questions | type, prompt | Add a question to a survey |
DELETE | /api/surveys/{id}/questions/{qid} | — | Delete a question |
GET | /api/surveys/{id}/preflight | — | Pre-publish health check (errors / warnings) |
POST | /api/surveys/{id}/publish | — | Publish; returns share_token + ready-to-share share_url |
POST | /api/surveys/{id}/unpublish | — | Revert to draft (share_token preserved) |
GET | /api/public/surveys/by-token/{token} | — (public) | Resolve a short /s/{token} link to its survey_id + canonical share_url |
POST | /api/surveys/{id}/synthetic-test | profile, participant_count, language | Generate synthetic (AI) respondents to pilot a survey |
Survey blocks & between-subjects randomization (A/B)
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/surveys/{id}/blocks | title, is_randomizer, randomizer_pick_count, parent_block_id | Create a block — a randomizer parent, or a condition child (parent_block_id) |
PUT | /api/surveys/{id}/blocks/{block_id} | title, is_randomizer, randomizer_pick_count | Update a block |
DELETE | /api/surveys/{id}/blocks/{block_id} | — | Delete a block (the questions inside are kept, just unassigned) |
POST | /api/surveys/{id}/blocks/reorder | block_ids[], parent_block_id? | Reorder top-level blocks, or a randomizer’s children |
PUT | /api/surveys/{id}/questions/{question_id}/move | block_id (or null) | Assign a question to a condition (child) block, or unassign it |
Image stimuli
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/surveys/upload-image | file (multipart) | Upload an image; returns a URL to use as a question prompt_image_url |
GET | /api/surveys/images/{image_uuid} | — | Fetch an uploaded image |
Survey utilities, exports & results
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/surveys/from-wizard | project_id, goal, audience, language, depth, n_questions | AI-generate a first-draft survey |
POST | /api/surveys/from-template | project_id, template_id | Instantiate a survey from a starter template |
POST | /api/surveys/import/preview | file (multipart) | Parse a .qsf/.pdf/.docx into a draft (no persist) |
POST | /api/surveys/import/create | reviewed draft | Persist a reviewed imported draft |
POST | /api/surveys/{id}/duplicate | — | Deep-copy a survey (questions + blocks) |
POST | /api/surveys/{id}/preview-session | — | Start a preview session (does not count toward quota) |
GET | /api/surveys/{id}/synthetic-cost | — | Estimate the credit cost of a synthetic test |
GET | /api/surveys/{id}/responses | limit?, offset? | List submitted responses |
DELETE | /api/surveys/{id}/responses/{session_uuid} | — | Delete one submitted response |
GET | /api/surveys/{id}/export/csv | — | Export responses as CSV |
GET | /api/surveys/{id}/export/xlsx | — | Export responses as Excel |
GET | /api/surveys/{id}/export/definition/qlt | — | Export the questionnaire as re-importable JSON (.qlt) |
GET | /api/surveys/{id}/export/definition/docx | — | Export the questionnaire as a Word document |
GET | /api/surveys/{id}/translations | — | Full translation table (source + stored translations) |
POST | /api/surveys/{id}/translations/{lang}/auto | — | LLM-translate the whole survey into {lang} and store it |
PUT | /api/surveys/{id}/translations/{lang} | survey, questions | Save hand-edited translations for {lang} |
DELETE | /api/surveys/{id}/translations/{lang} | — | Remove a language |
Analysis (on completed interviews)
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/analysis/interview/{uuid}/coding | — | Inductive coding / theme analysis |
POST | /api/analysis/interview/{uuid}/complete | — | Quality rating + inductive coding |
GET | /api/analysis/interview/{uuid}/results | — | Fetch computed analysis results |
Key management (session login only — not via an API key): POST /api/keys create, GET /api/keys list, DELETE /api/keys/{id} revoke.
Parameters & enums
Allowed values for the project fields (send them exactly as written, uppercase):
| Field | Allowed values | Default |
|---|---|---|
interview_type | STRUCTURED · SEMI_STRUCTURED | — (required) |
interview_language | ENGLISH · CHINESE · FRENCH · DUTCH · NORWEGIAN · GERMAN · SPANISH · PORTUGUESE · JAPANESE · ARABIC · FLEXIBLE | — (required) |
interaction_mode | TEXT_TO_TEXT · TEXT_TO_VOICE · VOICE_TO_TEXT · VOICE_TO_VOICE | VOICE_TO_VOICE |
max_duration | integer, 1–120 (minutes) | 60 |
transcription_engine | elevenlabs · google · whisper |
Required to create a project: name, outline, interview_type, interview_language. Everything else is optional.
Survey question type (one per question):
short_text · long_text · single_choice · multi_choice · dropdown · number · date · email · phone · rating_scale · nps · slider · bipolar_scale · ranking · constant_sum · matrix · yes_no · image_choice · file_upload · info_text · section_break · page_break · ai_interviewChoice-style types (single_choice, multi_choice, dropdown, ranking, image_choice) take an options_json array of strings. ai_follow_up_intensity is one of none (default) · light · deep and controls how hard the AI probes each answer.
Embed the interviewer (website, Qualtrics, Lovable)
Any page can host an interview with one script tag. Pass your own participant id and it is stored with the interview as external_participant_id, so you can join transcripts back to your survey or CRM.
<div data-qualitati-interview="SHARE_URL" data-participant="PARTICIPANT_ID" data-mode="text"></div>
<script src="https://qualitati.com/embed.js" async></script>
<script>
document.addEventListener('qualitati:complete', function (e) {
// e.detail = { interview_uuid, participant_id, share_url, mode }
});
</script>Attributes: data-qualitati-interview (the share link id — the part after /interview/), data-participant (optional, up to 200 characters), data-mode (text or voice), data-height (default 640px). The iframe needs microphone access for voice mode — do not sandbox it.
Qualtrics
Add a Text/Graphic question and paste this into its JavaScript editor. Piped text supplies the ResponseID; the Next button unlocks when the interview ends. Declare an embedded-data field qualitati_interview_id in Survey Flow to keep the interview id in your Qualtrics export.
Qualtrics.SurveyEngine.addOnload(function () {
var q = this;
q.hideNextButton();
var box = document.createElement('div');
box.setAttribute('data-qualitati-interview', 'SHARE_URL');
box.setAttribute('data-participant', '${e://Field/ResponseID}');
q.getQuestionContainer().appendChild(box);
window.onQualitatiComplete = function (d) {
Qualtrics.SurveyEngine.setEmbeddedData('qualitati_interview_id', d.interview_uuid);
q.showNextButton();
};
var s = document.createElement('script');
s.src = 'https://qualitati.com/embed.js';
s.async = true;
document.head.appendChild(s);
});Pull the results back
curl https://starfish-app-73rfk.ondigitalocean.app/api/projects/uuid/$PROJECT_UUID/interviews \
-H "X-API-Key: $QUALITATI_API_KEY"
# each interview carries "external_participant_id" — the value your page passedReady-made snippets pre-filled with your share link live in each project's settings under Share links → Embed & integrate.
Errors
Errors use standard HTTP status codes and a JSON body shaped like { "detail": "human-readable message" }.
| Status | Meaning |
|---|---|
200 / 201 | Success. |
400 | Bad request — invalid or missing fields (e.g. empty interview_type, or “No conversation data available for analysis”). |
401 | Unauthorized — missing key (“Not authenticated”) or a bad/revoked key (“Invalid or revoked API key”). |
402 | Payment required — not enough credits/quota for this action (e.g. “Insufficient credits. Need ~1, have 0.”). |
404 | Not found — e.g. “Project not found”, “Interview not found”. |
429 | Too many requests — analysis is rate-limited (“Rate limit exceeded for analysis. Please retry shortly.”). |
Connect your AI agent (MCP)
QualiTaTi runs a hosted Model Context Protocol server at https://api.qualitati.com/mcp. Any MCP client can call it over HTTPS with your personal API key, so Claude Code, Codex, OpenCode, Cursor or Claude Desktop can list your projects, read interviews and analyses, set up new studies and surveys, and generate share links, in plain language. Same tools, same key, same billing as the REST API. Create a key in your profile, then pick your client below.
Transport: streamable HTTP. Auth: OAuth 2.1 (clients that support it discover the authorization server automatically and open a QualiTaTi consent page — no key to copy), or a personal API key as Authorization: Bearer qt_… / X-API-Key: qt_…. No local install.
Claude Code
Install the QualiTaTi plugin — the server plus skills that teach Claude our workflows and /qualitati:new-study and /qualitati:results commands — then export your key:
/plugin marketplace add lanceyuu/qualitati-agent-plugins
/plugin install qualitati@qualitati-agent-plugins
export QUALITATI_API_KEY=qt_your_key_hereServer only, with OAuth (run /mcp in Claude Code afterwards and choose Authenticate — a QualiTaTi consent page opens):
claude mcp add --transport http qualitati https://api.qualitati.com/mcpOr with your API key instead of OAuth:
claude mcp add --transport http qualitati https://api.qualitati.com/mcp \
--header "Authorization: Bearer qt_your_key_here"Or commit a project-level .mcp.json so a whole team gets it:
{
"mcpServers": {
"qualitati": {
"type": "http",
"url": "https://api.qualitati.com/mcp",
"headers": { "Authorization": "Bearer ${QUALITATI_API_KEY}" }
}
}
}ChatGPT
QualiTaTi is in the ChatGPT plugin store — add it in ChatGPT and sign in with your QualiTaTi account when asked (OAuth, no key to copy).
Codex CLI
codex plugin marketplace add lanceyuu/qualitati-agent-plugins
codex plugin add qualitati@qualitati-agent-plugins
export QUALITATI_API_KEY=qt_your_key_hereOr add the server directly to ~/.codex/config.toml (or a trusted project's .codex/config.toml) and export QUALITATI_API_KEY in your shell:
[mcp_servers.qualitati]
url = "https://api.qualitati.com/mcp"
bearer_token_env_var = "QUALITATI_API_KEY"OpenCode
// opencode.json
{
"mcp": {
"qualitati": {
"type": "remote",
"url": "https://api.qualitati.com/mcp",
"enabled": true,
"headers": { "Authorization": "Bearer {env:QUALITATI_API_KEY}" }
}
}
}Cursor
Paste your key and click — Cursor opens with the server pre-configured. The key never leaves this page.
Or by hand:
// .cursor/mcp.json
{
"mcpServers": {
"qualitati": {
"url": "https://api.qualitati.com/mcp",
"headers": { "Authorization": "Bearer qt_your_key_here" }
}
}
}Claude Desktop
Claude Desktop's custom connectors cannot send a fixed header, so bridge it with mcp-remote in claude_desktop_config.json:
{
"mcpServers": {
"qualitati": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.qualitati.com/mcp",
"--header", "Authorization: Bearer qt_your_key_here"]
}
}
}Any other MCP client
Point it at https://api.qualitati.com/mcp. Clients that implement MCP authorization (OAuth 2.1 with dynamic client registration and PKCE) need nothing else: discovery lives at https://starfish-app-73rfk.ondigitalocean.app/.well-known/oauth-protected-resource. Tokens are scoped read / write, expire after an hour and refresh for 30 days.
The server is listed on the official MCP Registry as io.github.lanceyuu/qualitati; the plugin source and every config above live at github.com/lanceyuu/qualitati-agent-plugins.
Then just ask
Create a semi-structured interview project about onboarding
friction, in English, voice-to-voice, and give me the share link.Tools: project_list, project_get, project_create, project_share, project_interviews, interview_get, interview_import, analysis_summary, analysis_digest, analysis_run, analysis_results, survey_create, survey_add_question, survey_publish. A human participant still conducts the interviews via the share link; the agent sets studies up and reads the results.
Limits & notes
A key inherits your plan, entitlements, and quota. API calls are gated and billed exactly like the same action in the web app — nothing bypasses a paywall or rate limit (you may see
402if you're out of credits).Analysis endpoints are rate-limited to about 5 calls/minute per user (
429when exceeded).You can hold up to 20 active keys. Each can be named and given an optional expiry, and revoked anytime from your profile. The plaintext is shown only once, at creation.
Need an endpoint that isn't exposed yet? Contact us.
Ready to start? Create an API key →
