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.
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:
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-level | Response-level | |
|---|---|---|
| Configured by | media_config at initialize | a MEDIA_RESPONSE column in column_mapping |
| Filename comes from | column values encoded in the filename | the cell value in that column |
| Use it for | a file belonging to a respondent as a whole, e.g. a diary video | a 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:
{
"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_columnsis required and must be non-empty. Each entry must exactly match a CSV column header.delimiterdefaults 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.mp4when two key columns are configured — is skipped by respondent-level linking, though it may still match viaMEDIA_RESPONSE.
One key column. Use when a single identifier like email is enough:
{
"media_config": { "respondent_key_columns": ["Email"] },
"column_mapping": { "Email": "Email", "Feedback": "Open-ended" }
}
Email,Feedback
alice@example.com,Great product
bob@example.com,Needs improvement
| Registered filename | Stem (no extension) | Matches column | Links to |
|---|---|---|---|
alice@example.com.mp4 | alice@example.com | Email = alice@example.com | Alice's response |
bob@example.com.mp4 | bob@example.com | Email = bob@example.com | Bob'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:
{
"media_config": {
"respondent_key_columns": ["Segment", "Name"],
"delimiter": "__"
},
"column_mapping": {
"Segment": "Single choice",
"Name": "Name",
"Feedback": "Open-ended"
}
}
Segment,Name,Feedback
enterprise,Alice,Great product
smb,Bob,Needs improvement
| Registered filename | Stem split on __ | Must match row |
|---|---|---|
enterprise__Alice.mp4 | enterprise + Alice | Segment=enterprise, Name=Alice |
smb__Bob.mp4 | smb + Bob | Segment=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:
{
"column_mapping": {
"Name": "Name",
"Email": "Email",
"Feedback": "Open-ended",
"Recording": "MEDIA_RESPONSE"
}
}
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 filename | Links to |
|---|---|
clip-alice-001.mp4 | Row 1 — Alice's response (Recording cell equals filename) |
clip-bob-002.mp4 | Row 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:
{
"media_config": { "respondent_key_columns": ["Email"] },
"column_mapping": {
"Email": "Email",
"Diary intro": "Open-ended",
"Screen recording": "MEDIA_RESPONSE"
}
}
| Registered filename | Linking mode | How it matches |
|---|---|---|
alice@example.com.mp4 | Respondent | Stem alice@example.com matches Email column |
session-42.mp4 | Response | Cell 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.
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 upload | 1–100 |
| Max size per part | 50 MB |
| Max rows per part | 20,000 (no cap on the total) |
| Media file size | No per-file limit |
| Supported media | mp4, mov, avi, m4v, webm, mp3, m4a, wav, aac |
| Presigned URL lifetime | 1 hour |
| Rate limit | 20 requests/minute per API key |
Next steps#
- CSVs API reference — exact parameters, responses, and status values
- Import CSV — the underlying flow in full
- Troubleshooting — unlinked media and partial failures