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.

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 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#

text
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.

TypeMax size
Audio / video10 GB
Documents / images500 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.

CategoryExtensions
Videomp4, mov, avi, webm, mkv, m4v, wmv, mpeg
Audiomp3, m4a, wav, flac, ogg, aac, aiff, aif, amr
Documentspdf, ppt, pptx, doc, docx, xls, xlsx, txt
Imagespng, jpg, jpeg, gif, bmp, tiff, webp

Complete example#

python
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#