Import CSV

Import tabular data that already lives in another system: support tickets, CRM records, product feedback tables, NPS exports. Marvin turns each row into a response and each mapped column into a question you can filter and chart on.

Four guides, one API. Every CSV import runs through the same endpoints — what changes is the shape of your data and how you map it. Use Import unstructured data for free-text corpora, Import surveys for survey instruments, and Import multimedia CSV when audio or video comes with the rows.

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.

The data#

A support ticket export is a good example of the shape this guide covers — a stable schema, a mix of free text and categorical fields, and a couple of columns that exist only for your own systems:

csv
Ticket ID,Created At,Customer Email,Priority,Channel,Subject,Description,Recommend
TCK-1041,2026-03-02,alice@example.com,High,Email,Export never finishes,"The export button spins forever on projects with more than 50 charts.",3
TCK-1042,2026-03-02,bob@example.com,Low,Chat,Dark mode request,"Would love a dark theme for late-night analysis sessions.",9

Each row becomes one response. Description and Subject become qualitative notes you can code and search; Priority and Channel become filters; Ticket ID is yours, not Marvin's, so it gets ignored.

Mapping your columns #

column_mapping tells Marvin what each CSV column means; auto_map lets an AI classifier guess. Explicit mappings always win. See Column types for the full list and the exact resolution order.

For the ticket export above:

json
{
  "auto_map": false,
  "column_mapping": {
    "Ticket ID": "Do nothing",
    "Created At": "Timestamp",
    "Customer Email": "Email",
    "Priority": "Single choice",
    "Channel": "Single choice",
    "Subject": "Open-ended",
    "Description": "Open-ended",
    "Recommend": "NPS"
  }
}

Four strategies, depending on how much you know about the data:

json
{
  "auto_map": false,
  "column_mapping": {
    "Customer Email": "Email",
    "Description": "Open-ended",
    "Priority": "Single choice"
  }
}

Map everything yourself. Use this when the export schema is known and stable — which it usually is when it comes out of a ticketing or CRM system.

Mapping is fixed at initialize. column_mapping, auto_map, and media_config can't be changed afterwards, and an append reuses whatever you set here. Get the mapping right before you upload.

Run the 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

1. Pick a project#

GET /import/projects returns the projects your key can reach, or create one.

2. Initialize the upload#

POST /import/surveys/initialize with the project, a survey name, file_type: "CSV", how many parts you'll upload, and your column mapping. You get back an upload_key (the handle for every later call), a survey_key, and one presigned URL per part.

Pass an idempotency_key if the caller might retry: the same key from the same API key returns the existing upload instead of creating a duplicate survey.

3. Upload the CSV parts to S3#

POST each part to its upload_url with every entry from upload_fields as a form field, ordered before the file field.

Splitting into parts is about size, not merging:

  • 50 MB per part, enforced by S3.
  • Up to 20,000 rows per part. There's no cap on the total across parts.
  • Every part needs the header row, and all headers must be identical. Parts are processed independently and sequentially — they're never concatenated.

Signed URLs last an hour; retry a part issues a fresh one.

4. Complete#

POST /import/surveys/{upload_key}/complete verifies that every part exists in S3 before it enqueues anything, so a 400 here with missing_parts means processing hasn't started and you can simply re-upload what's listed and call it again.

Parts don't roll back. If part 3 of 5 fails validation, parts 1–2 are already saved to the survey and parts 4–5 are untouched. Read error_details for the failing part, retry just that part, re-upload it, and call complete again — already-processed parts are skipped. Don't retry a part whose status is PROCESSED; its rows are already in the survey and re-uploading would duplicate them.

5. Poll for status#

GET /import/surveys/{upload_key}/status until status is COMPLETED or FAILED. The response breaks down per part (row_count, part status), plus a processing summary with the response and question counts.

Adding rows later #

To extend a survey you already imported, call POST .../append with the number of new parts, upload them, and call complete again. Append reuses the original column_mapping, media_config, and auto_map — those can't be changed — and each new part must carry a header row with all of the existing survey's question columns. Extra columns are ignored rather than imported.

This is how you keep a ticket import current: run it nightly with the day's new rows rather than re-importing the whole export.

Use GET /import/surveys to find the upload_key of a previous import.

Complete example#

python
import time
import requests

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

# 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 upload
init = requests.post(
    f"{BASE_URL}/v1/developer/import/surveys/initialize",
    headers=headers,
    json={
        "project_id": project_id,
        "name": "Support tickets — March 2026",
        "file_type": "CSV",
        "num_parts": 1,
        "column_mapping": {
            "Ticket ID": "Do nothing",
            "Created At": "Timestamp",
            "Customer Email": "Email",
            "Priority": "Single choice",
            "Channel": "Single choice",
            "Subject": "Open-ended",
            "Description": "Open-ended",
            "Recommend": "NPS",
        },
    },
).json()
upload_key = init["upload_key"]
part = init["upload_urls"][0]

# 4. Upload the CSV to S3 (max 50 MB and 20,000 rows per part)
with open("tickets.csv", "rb") as f:
    requests.post(
        part["upload_url"],
        data=part["upload_fields"],
        files={"file": f},
    ).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']} tickets.")
        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)
Presigned URL lifetime1 hour
Rate limit20 requests/minute per API key

Next steps#