Import unstructured data

Import text that has no survey structure: app store reviews, open-ended feedback exports, sales call notes, chat transcripts, community posts. There's usually one column that carries the substance and a handful of metadata columns around it.

The mechanics are the same CSV import as everywhere else — what differs is that you lean on auto_map and Open-ended rather than describing a questionnaire.

One row, one document. Each row becomes a response and each Open-ended cell becomes a note you can code, search, and pull into an analysis. If your unstructured content is a file rather than a cell — an audio recording, a PDF, an image — use Importing files to Marvin instead.

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.

Shaping the text into rows#

Unstructured sources rarely arrive as a CSV. Whatever the export format, you need one row per document before you import. An app store review dump looks like this once flattened:

csv
Review ID,Posted At,Store,Rating,Review
r-88213,2026-02-14,iOS,2,"Sync stopped working after the last update. I've reinstalled twice and it still hangs on 'connecting'."
r-88214,2026-02-14,Android,5,"Honestly the transcript search is the reason we renewed. Finding a quote takes seconds now."

Two rules worth respecting:

  • One document per row. Don't concatenate ten reviews into one cell — Marvin codes at the note level, and merged text can't be split back apart afterwards.
  • Keep a stable identifier. Map it to Do nothing so it rides along without becoming a question. It's how you reconcile Marvin's analysis with your source system later.

Newlines inside a quoted CSV field are fine. Commas and quotes need standard CSV escaping — write the file with a real CSV writer rather than string concatenation.

Mapping mostly-text columns#

Free-text corpora are the one case where auto_map: true earns its keep: the classifier reliably detects Name, Email, Timestamp, and Open-ended, which is most of what you have. Pin the rest explicitly.

json
{
  "auto_map": true,
  "column_mapping": {
    "Review ID": "Do nothing",
    "Store": "Single choice",
    "Rating": "Single choice",
    "Review": "Open-ended"
  }
}

Review is mapped explicitly even though auto_map would likely catch it — the column that carries your actual content is the one you least want to leave to a classifier. Explicit mappings always win over AI detection; see Column types for the resolution order, and choosing a mapping strategy for the other approaches.

A column that ends up unmapped is ignored, not imported. If a source column falls through both auto_map and your column_mapping, its content never reaches Marvin. Check the question count in the status response against what you expected.

Run the import#

The flow is identical to any other CSV 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

Text corpora are where the 20,000-row-per-part limit actually bites — a year of reviews or support replies runs well past it. Split by row count first, then check that no part exceeds 50 MB, and remember that every part needs an identical header row. Parts are processed independently, never concatenated.

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

Complete example#

Splits a long corpus across parts and uploads each one.

python
import csv
import io
import math
import time
import requests

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

# Read the corpus and split it into parts, each carrying the header row
with open("reviews.csv", newline="", encoding="utf-8") as f:
    reader = csv.reader(f)
    header = next(reader)
    rows = list(reader)

num_parts = max(1, math.ceil(len(rows) / ROWS_PER_PART))

# 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 — auto_map handles the text, explicit mappings pin the rest
init = requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/initialize",
    headers=headers,
    json={
        "project_id": project_id,
        "name": "App store reviews — 2026",
        "file_type": "CSV",
        "num_parts": num_parts,
        "auto_map": True,
        "column_mapping": {
            "Review ID": "Do nothing",
            "Store": "Single choice",
            "Rating": "Single choice",
            "Review": "Open-ended",
        },
    },
).json()
upload_key = init["upload_key"]

# 4. Write each chunk to S3 with the header row repeated
for part in init["upload_urls"]:
    start = (part["part_number"] - 1) * ROWS_PER_PART
    chunk = rows[start : start + ROWS_PER_PART]

    buffer = io.StringIO()
    writer = csv.writer(buffer)
    writer.writerow(header)
    writer.writerows(chunk)

    requests.post(
        part["upload_url"],
        data=part["upload_fields"],
        files={"file": ("part.csv", buffer.getvalue().encode("utf-8"))},
    ).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":
        print(f"Imported {status['processing']['total_responses']} documents.")
        break
    if status["status"] == "FAILED":
        print(f"Import failed: {status['error_details']}")
        break

    time.sleep(5)

Next steps#