Importing files to Marvin
Import audio, video, documents, and images into a Marvin project. Audio and video are transcribed automatically; documents and images are processed for extraction. One file becomes one Marvin interview or document.
This is the standalone file flow. To attach media to respondents or responses as part of a CSV import, use Import multimedia CSV instead. To publish a document as a research artifact, use Importing insights into Marvin.
Before you start#
You need an access token with the project:read and file: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 file: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#
1. List projects → pick a project_id
2. Initialize → get wav_key + presigned S3 upload URL
3. Upload to S3 → POST the file directly to S3
4. Complete → signal Marvin to start processing
5. Poll status → wait for completed
Files go straight to S3 rather than through Marvin, which keeps 10 GB video uploads off the API. Initialize hands you a signed URL; complete tells Marvin the bytes have landed.
1. Pick a project#
GET /import/projects returns the projects your key can reach. Take an id from the response, or create a project — worth doing when you want specific transcription languages or PII settings applied to everything you're about to import.
2. Initialize the upload#
POST /import/files/initialize with project_id and file_name. The extension drives both the media type and the S3 content type, so send the real filename — an unsupported extension is rejected here with 400 rather than failing later.
You get back a wav_key (the handle for every later call) plus upload_url and upload_fields.
3. Upload to S3#
POST to upload_url with every entry from upload_fields as a form field, ordered before the file field. S3 evaluates the policy fields in order and ignores anything after the file, so getting this backwards is the most common cause of a rejected upload.
| Type | Max size |
|---|---|
| Audio / video | 10 GB |
| Documents / images | 500 MB |
The signed URL expires after an hour. There's no retry endpoint for files, so if it lapses, call initialize again — that produces a new wav_key.
4. Complete#
POST /import/files/{wav_key}/complete starts processing: transcription for audio and video, extraction for documents. If the object isn't in S3 yet you'll get a 400 telling you so, and nothing is enqueued.
Complete is safe to repeat — once the file is linked it returns 200 with "status": "processing".
5. Poll for status#
GET /import/files/{wav_key}/status until status is completed or error. Transcription latency scales with the duration of the media and current queue load, so poll every 10 seconds rather than tightly — status calls count against the 20 requests/minute limit.
To see everything you've imported later, use GET /import/files — paginated, newest first. Survey imports also appear there under their underlying wav.
Supported file types#
Extensions are case-insensitive.
| Category | Extensions |
|---|---|
| Video | mp4, mov, avi, webm, mkv, m4v, wmv, mpeg |
| Audio | mp3, m4a, wav, flac, ogg, aac, aiff, aif, amr |
| Documents | pdf, ppt, pptx, doc, docx, xls, xlsx, txt |
| Images | png, jpg, jpeg, gif, bmp, tiff, webp |
Complete example#
import json
import time
import requests
BASE_URL = "https://app.heymarvin.com"
CLIENT_ID = "cid-mrv_..."
CLIENT_SECRET = "sk-mrv_..."
PROJECT_ID = 42
LOCAL_FILE = "customer-interview.mp4"
# 1. Token
token = requests.post(
f"{BASE_URL}/api/v1/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "project:read file:write",
},
).json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Initialize
init = requests.post(
f"{BASE_URL}/api/v1/developer/import/files/initialize",
headers=headers,
json={"project_id": PROJECT_ID, "file_name": LOCAL_FILE},
).json()
wav_key = init["wav_key"]
# 3. Upload to S3
files = {"file": open(LOCAL_FILE, "rb")}
form = dict(init["upload_fields"])
requests.post(init["upload_url"], data=form, files=files).raise_for_status()
# 4. Complete
requests.post(
f"{BASE_URL}/api/v1/developer/import/files/{wav_key}/complete",
headers=headers,
).raise_for_status()
# 5. Poll until done
while True:
status = requests.get(
f"{BASE_URL}/api/v1/developer/import/files/{wav_key}/status",
headers=headers,
).json()
print(status["status"])
if status["status"] in ("completed", "error"):
break
time.sleep(10)
print(json.dumps(status, indent=2))
Next steps#
- Files API reference — exact parameters, responses, and status values
- Troubleshooting — rejected S3 uploads, expired URLs, missing-file errors on complete