Import multimedia CSV

Import unmoderated research: participants complete a study on their own, and the platform hands you a CSV of responses plus a folder of recordings. This guide covers uploading both together so Marvin links each file to the right place and transcribes it.

Same CSV import, plus media. Everything in Import CSV still applies — the rows, the parts, the column mapping. What's added here is registering media files and telling Marvin how filenames map onto your data.

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.

How the flow works#

Two steps slot into the standard CSV flow before you complete:

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

Complete verifies that every part and every registered media file exists in S3 before it enqueues anything, so a 400 with missing_media_files means nothing has started and you can upload what's listed and call it again.

Linking modes #

You can attach audio and video to a CSV import and have Marvin link each file to the right place. Linked files are transcribed automatically.

There are two independent linking strategies, and you can use either or both on the same upload. The difference is where the filename comes from.

Respondent-levelResponse-level
Configured bymedia_config at initializea MEDIA_RESPONSE column in column_mapping
Filename comes fromcolumn values encoded in the filenamethe cell value in that column
Use it fora file belonging to a respondent as a whole, e.g. a diary videoa file belonging to one answer, e.g. a screen recording per task

Matching is exact and case-sensitive in both modes. Marvin runs respondent-level linking first, then response-level; a file unmatched by both is marked FAILED.

Respondent-level: the filename encodes who#

Configure the columns that identify a respondent and the delimiter joining them:

json
{
  "media_config": {
    "respondent_key_columns": ["Segment", "Name"],
    "delimiter": "__"
  }
}

With this config, enterprise__alice.mp4 matches the respondent whose Segment is enterprise and Name is alice. Details that matter:

  • respondent_key_columns is required and must be non-empty. Each entry must exactly match a CSV column header.
  • delimiter defaults to __. The filename stem (minus extension) is split into exactly as many segments as there are key columns.
  • A file matches only when every key column value points at the same respondent.
  • A filename with the wrong number of segments — say alice.mp4 when two key columns are configured — is skipped by respondent-level linking, though it may still match via MEDIA_RESPONSE.

One key column. Use when a single identifier like email is enough:

json
{
  "media_config": { "respondent_key_columns": ["Email"] },
  "column_mapping": { "Email": "Email", "Feedback": "Open-ended" }
}
csv
Email,Feedback
alice@example.com,Great product
bob@example.com,Needs improvement
Registered filenameStem (no extension)Matches columnLinks to
alice@example.com.mp4alice@example.comEmail = alice@example.comAlice's response
bob@example.com.mp4bob@example.comEmail = bob@example.comBob's response

With a single key column the delimiter is unused — the whole stem is the lookup value.

Multiple key columns. Use when identity spans columns:

json
{
  "media_config": {
    "respondent_key_columns": ["Segment", "Name"],
    "delimiter": "__"
  },
  "column_mapping": {
    "Segment": "Single choice",
    "Name": "Name",
    "Feedback": "Open-ended"
  }
}
csv
Segment,Name,Feedback
enterprise,Alice,Great product
smb,Bob,Needs improvement
Registered filenameStem split on __Must match row
enterprise__Alice.mp4enterprise + AliceSegment=enterprise, Name=Alice
smb__Bob.mp4smb + BobSegment=smb, Name=Bob

Response-level: a column holds the filename#

This is the usual shape for unmoderated studies, where each task in the study produces its own recording. Map the column whose cells contain filenames:

json
{
  "column_mapping": {
    "Name": "Name",
    "Email": "Email",
    "Feedback": "Open-ended",
    "Recording": "MEDIA_RESPONSE"
  }
}
csv
Name,Email,Feedback,Recording
Alice,alice@example.com,Great product,clip-alice-001.mp4
Bob,bob@example.com,Needs improvement,clip-bob-002.mp4
Registered filenameLinks to
clip-alice-001.mp4Row 1 — Alice's response (Recording cell equals filename)
clip-bob-002.mp4Row 2 — Bob's response

For each row, Marvin attaches the named file to that response item and, for audio and video, creates an empty media note that fills in after transcription. The cell value must exactly equal the registered filename, extension included. No media_config is needed.

Using both modes together#

Respondent-level diary videos and per-task recordings can coexist in one upload:

json
{
  "media_config": { "respondent_key_columns": ["Email"] },
  "column_mapping": {
    "Email": "Email",
    "Diary intro": "Open-ended",
    "Screen recording": "MEDIA_RESPONSE"
  }
}
Registered filenameLinking modeHow it matches
alice@example.com.mp4RespondentStem alice@example.com matches Email column
session-42.mp4ResponseCell in Screen recording column equals session-42.mp4

auto_map: true never assigns MEDIA_RESPONSE — AI auto-mapping only detects Name, Email, Timestamp, and Open-ended columns. Media columns must be mapped explicitly.

The removed media_config keys response_key_columns and media_questions are no longer supported — use a MEDIA_RESPONSE column instead. Sending them returns 400.

Registering and uploading the files#

Declare every filename with POST .../media/register to get a presigned URL each, then upload them to S3. You can register in one call or several; re-registering a name is a no-op unless the file is still PENDING, in which case you get a fresh URL.

Only mp4, mov, avi, m4v, webm, mp3, m4a, wav, and aac are supported — anything else is marked FAILED during linking. There's no per-file size limit on media, and URLs expire after an hour (retry for a new one).

Check media_files, not just the overall status. An upload can reach COMPLETED while individual files show FAILED — linking is per file. A file fails when nothing matched it: an unsupported extension, a stem that matches no respondent, or a MEDIA_RESPONSE cell that doesn't exactly equal the registered filename.

Complete example#

An unmoderated study: one CSV of task responses, one screen recording per row.

python
import time
import requests

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

# {registered filename: local path}
media_to_upload = {
    "clip-alice-001.mp4": "recordings/alice-task1.mp4",
    "clip-bob-002.mp4": "recordings/bob-task1.mp4",
}

# 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 — the Recording column holds the filenames
init = requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/initialize",
    headers=headers,
    json={
        "project_id": project_id,
        "name": "Checkout usability study",
        "file_type": "CSV",
        "num_parts": 1,
        "column_mapping": {
            "Name": "Name",
            "Email": "Email",
            "Feedback": "Open-ended",
            "Recording": "MEDIA_RESPONSE",
        },
    },
).json()
upload_key = init["upload_key"]
part = init["upload_urls"][0]

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

# 4b. Register the media filenames, then upload each file
registered = requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/{upload_key}/media/register",
    headers=headers,
    json={"files": list(media_to_upload)},
).json()

for media in registered["media_files"]:
    with open(media_to_upload[media["filename"]], "rb") as f:
        requests.post(
            media["upload_url"],
            data=media["upload_fields"],
            files={"file": f},
        ).raise_for_status()

# 5. Complete — fails with missing_media_files if anything didn't land
requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/{upload_key}/complete",
    headers=headers,
).raise_for_status()

# 6. Poll until done, then check each file linked
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']} responses.")
        for media in status["media_files"]:
            if media["status"] != "LINKED":
                print(f"  unlinked: {media['filename']} ({media['status']})")
        break
    if status["status"] == "FAILED":
        print(f"Import failed: {status['error_details']}")
        break

    time.sleep(5)

Limits at a glance#

Parts per upload1–100
Max size per part50 MB
Max rows per part20,000 (no cap on the total)
Media file sizeNo per-file limit
Supported mediamp4, mov, avi, m4v, webm, mp3, m4a, wav, aac
Presigned URL lifetime1 hour
Rate limit20 requests/minute per API key

Next steps#