Import surveys

Import responses from a survey instrument — Qualtrics, SurveyMonkey, Typeform, or your own form. Marvin turns each row into a response and each mapped column into a question, so scored and categorical answers stay chartable instead of collapsing into text.

This is the questionnaire case. If your CSV is an operational export rather than a survey, use Import CSV. If it's mostly free text, use Import unstructured data. If participants recorded audio or video, use Import multimedia CSV.

Before you start#

You need an access token with the project:read and survey:write scopes. Add project:write too if you want to create the project via the API.

bash
export BASE_URL="https://app.heymarvin.com"
export CLIENT_ID="cid-mrv_..."
export CLIENT_SECRET="sk-mrv_..."

TOKEN=$(curl -s -X POST "$BASE_URL/api/v1/oauth/token" \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "scope=project:read survey:write" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")

The token lasts 1 hour. See Access and authorization for the full setup.

The data#

A survey export has one column per question, and the column header is usually the question text:

csv
Respondent Name,Email Address,Submitted At,Plan,How likely are you to recommend us?,Which features do you use?,What would you improve?
Alice Chen,alice@example.com,2026-03-02,Enterprise,9,"Transcripts;Search;Tags","Bulk export would save me a morning every week."
Bob Ortiz,bob@example.com,2026-03-03,Starter,4,"Search","Onboarding assumed I already knew what a codebook was."

Marvin treats the header as the question and each cell as that respondent's answer.

Matching columns to question types#

Map every question column to the type that reflects how it was asked — that's what decides whether an answer becomes a filterable chart or a piece of qualitative text.

json
{
  "auto_map": false,
  "column_mapping": {
    "Respondent Name": "Name",
    "Email Address": "Email",
    "Submitted At": "Timestamp",
    "Plan": "Single choice",
    "How likely are you to recommend us?": "NPS",
    "Which features do you use?": "Multi choice",
    "What would you improve?": "Open-ended"
  }
}
Your question wasMap it toWhy
A 0–10 recommendation scoreNPSCharts as a Net Promoter Score, not an arbitrary number
Pick one optionSingle choiceBecomes a survey filter
Pick all that applyMulti choiceBecomes a multi-select filter
Rank these optionsRankingPreserves order rather than reading as text
A free-text boxOpen-endedBecomes a qualitative note you can code
Screener or internal fieldDo nothingKept out of the question set entirely

auto_map can't see question types. The classifier only detects Name, Email, Timestamp, and Open-ended. Leave a scored or categorical question to it and the answer lands as text or is ignored — which is exactly the data you most wanted to chart. Map question columns explicitly.

Full type list and resolution order: Column types. Other mapping strategies: choosing a mapping strategy.

Respondents and the Research Panel#

add_to_research_panel defaults to true, which adds each respondent to your Research Panel using the Name and Email columns. That's usually what you want for a survey — it's how you recontact people for follow-up research.

Set it to false for anonymous surveys, or when the responses came from a panel provider you don't own the relationship with.

json
{ "add_to_research_panel": false }

Run the import#

text
1. List projects   →  pick a project_id
2. Initialize      →  get presigned S3 upload URLs
3. Upload to S3    →  POST CSV parts directly to S3
4. Complete        →  signal Marvin to start processing
5. Poll status     →  wait for COMPLETED

Step details are in Import CSV; endpoint specifics are in the CSVs API reference.

Surveys that are still in field are the common case for adding rows later — append new responses as they come in rather than re-importing. Append reuses the original mapping, and every appended part must carry a header row with all of the existing survey's question columns.

Complete example#

python
import time
import requests

BASE_URL = "https://app.heymarvin.com/api"

# 1. Get access token
access_token = requests.post(
    f"{BASE_URL}/v1/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": "cid-mrv_your_client_id_here",
        "client_secret": "sk-mrv_your_secret_here",
        "scope": "project:read survey:write",
    },
).json()["access_token"]
headers = {"Authorization": f"Bearer {access_token}"}

# 2. Pick a project
projects = requests.get(
    f"{BASE_URL}/v1/developer/import/projects",
    headers=headers,
).json()["projects"]
project_id = projects[0]["id"]

# 3. Initialize — one entry per question column
init = requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/initialize",
    headers=headers,
    json={
        "project_id": project_id,
        "name": "Q1 Product Satisfaction Survey",
        "file_type": "CSV",
        "num_parts": 1,
        "add_to_research_panel": True,
        "column_mapping": {
            "Respondent Name": "Name",
            "Email Address": "Email",
            "Submitted At": "Timestamp",
            "Plan": "Single choice",
            "How likely are you to recommend us?": "NPS",
            "Which features do you use?": "Multi choice",
            "What would you improve?": "Open-ended",
        },
    },
).json()
upload_key = init["upload_key"]
part = init["upload_urls"][0]

# 4. Upload the responses to S3
with open("responses.csv", "rb") as f:
    requests.post(
        part["upload_url"],
        data=part["upload_fields"],
        files={"file": f},
    ).raise_for_status()

# 5. Complete
requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/{upload_key}/complete",
    headers=headers,
).raise_for_status()

# 6. Poll until done
while True:
    status = requests.get(
        f"{BASE_URL}/v1/developer/import/surveys/{upload_key}/status",
        headers=headers,
    ).json()

    if status["status"] == "COMPLETED":
        summary = status["processing"]
        print(f"{summary['total_responses']} responses, {summary['questions_detected']} questions.")
        break
    if status["status"] == "FAILED":
        print(f"Import failed: {status['error_details']}")
        break

    time.sleep(5)

Next steps#