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.
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:
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:
{
"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:
{
"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#
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#
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 upload | 1–100 |
| Max size per part | 50 MB |
| Max rows per part | 20,000 (no cap on the total) |
| 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 unstructured data — when the rows are mostly free text
- Troubleshooting — partial failures and append rejections