Developer API Guide — Survey Import
Import survey data programmatically via API keys and presigned S3 uploads.
Prerequisites#
Contact Support to generate an API Key (Client ID + Client Secret)
TOKEN=$(curl -s -X POST https://app.heymarvin.com/api/v1/oauth/token \
-d "grant_type=client_credentials" \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "client_secret=<YOUR_CLIENT_SECRET>" \
-d "scope=project:read survey:write" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
The token expires in 1 hour. Exchange again when it expires.
Import Flow#
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. List Projects#
curl -s https://app.heymarvin.com/api/v1/developer/import/projects \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response:
{
"projects": [
{ "id": 42, "name": "Alpha Research" },
{ "id": 43, "name": "Beta Onboarding" }
]
}
Requires project:read scope. Returns only projects accessible to your API key (team-shared for team keys, user-accessible for personal keys). Archived and deleted projects are excluded.
2. Initialize an Upload#
curl -s -X POST https://app.heymarvin.com/api/v1/developer/import/surveys/initialize \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_id": 42,
"name": "Q1 Customer Interviews",
"file_type": "CSV",
"num_parts": 1,
"add_to_research_panel": true,
"column_mapping": {
"Respondent Name": "Name",
"Email Address": "Email",
"Interview Date": "Timestamp",
"Feedback": "Open-ended",
"Plan Type": "Single choice"
}
}' | python3 -m json.tool
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
project_id | integer | Yes | ID from the list projects response |
name | string | Yes | Survey name (max 200 chars) |
file_type | string | Yes | Must be "CSV" |
num_parts | integer | Yes | Number of CSV parts to upload (1–100) |
column_mapping | object | No | Map CSV column names to types (see Column Mapping) |
auto_map | boolean | No | Use AI to auto-detect column types (default: false) |
add_to_research_panel | boolean | No | Add respondents to Research Panel (default: true) |
idempotency_key | string | No | Unique key to prevent duplicates (max 255 chars). Same key + same API key returns the existing upload. Same key + different API key returns 409 Conflict. |
Response (201):
{
"survey_key": "a1b2c3d4-...",
"upload_key": "e5f6a7b8-...",
"status": "AWAITING_UPLOAD",
"num_parts": 1,
"upload_urls": [
{
"part_number": 1,
"upload_url": "https://s3.amazonaws.com/bucket/",
"upload_fields": {
"key": "developer-uploads/10/e5f6a7b8-.../part-1.csv",
"Content-Type": "text/csv",
"x-amz-credential": "...",
"policy": "...",
"x-amz-signature": "..."
},
"s3_key": "developer-uploads/10/e5f6a7b8-.../part-1.csv"
}
],
"links": {
"complete": "/api/v1/developer/import/surveys/e5f6a7b8-.../complete",
"status": "/api/v1/developer/import/surveys/e5f6a7b8-.../status"
}
}
Rate limited to 20 requests/minute per API key.
3. Upload CSV Parts to S3#
Upload each part directly to S3 via presigned POST. All upload_fields must be sent as form fields before the file. S3 enforces a 50 MB limit per part.
curl -X POST "https://s3.amazonaws.com/bucket/" \
-F "key=developer-uploads/10/e5f6a7b8-.../part-1.csv" \
-F "Content-Type=text/csv" \
-F "x-amz-credential=..." \
-F "policy=..." \
-F "x-amz-signature=..." \
-F "file=@survey-data.csv"
For multi-part uploads, each part must include the CSV header row and all parts must have identical headers. Total row count across all parts must not exceed 20,000 rows. Presigned URLs expire after 1 hour — use the Retry endpoint to get fresh URLs.
4. Complete the Upload#
curl -s -X POST \
https://app.heymarvin.com/api/v1/developer/import/surveys/$UPLOAD_KEY/complete \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response (202):
{
"upload_key": "e5f6a7b8-...",
"status": "VALIDATING",
"message": "Processing started."
}
Marvin verifies all parts exist in S3 synchronously before enqueueing processing. If any parts are missing, the call returns 400 instead of starting:
{
"error": "Some parts have not been uploaded.",
"missing_parts": [2, 3]
}
Idempotency: Calling this endpoint again when processing is already underway or finished returns the current status with 200. If the upload is in FAILED state, this endpoint returns 400 with error_details — use the Retry endpoint to reset the upload before calling complete again.
5. Poll for Status#
curl -s \
https://app.heymarvin.com/api/v1/developer/import/surveys/$UPLOAD_KEY/status \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response:
{
"upload_key": "e5f6a7b8-...",
"survey_key": "a1b2c3d4-...",
"status": "COMPLETED",
"file_type": "CSV",
"num_parts": 1,
"parts": [
{ "part_number": 1, "status": "UPLOADED", "row_count": 250 }
],
"processing": {
"total_responses": 250,
"questions_detected": 5,
"is_mapped": true
},
"error_details": {},
"created_at": "2026-04-01T00:00:00Z",
"completed_at": "2026-04-01T00:01:30Z"
}
Status values:
| Status | Meaning |
|---|---|
AWAITING_UPLOAD | Parts not yet uploaded to S3 |
VALIDATING | Verifying parts and merging CSV |
PROCESSING | Survey being parsed and mapped |
COMPLETED | Import finished — survey available in Marvin |
FAILED | Import failed — see error_details |
6. Retry a Failed Part#
curl -s -X POST \
https://app.heymarvin.com/api/v1/developer/import/surveys/$UPLOAD_KEY/parts/1/retry \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response (200):
{
"part_number": 1,
"upload_url": "https://s3.amazonaws.com/bucket/",
"upload_fields": {
"key": "developer-uploads/10/e5f6a7b8-.../part-1.csv",
"Content-Type": "text/csv",
"AWSAccessKeyId": "...",
"policy": "...",
"signature": "..."
},
"s3_key": "developer-uploads/10/e5f6a7b8-.../part-1.csv"
}
Only allowed when status is AWAITING_UPLOAD or FAILED. If FAILED, calling retry resets the upload to AWAITING_UPLOAD and clears error_details. Re-upload the part using the new upload_url and upload_fields, then call complete again.
Column Mapping Reference#
column_mapping and auto_map control how Marvin interprets each CSV column. Explicit mappings always override AI detection.
Resolution priority:
- Explicit
column_mapping— column header matches a key → that type is used - AI recommendation (
auto_map: true) — classifier suggests a type (≥90% confidence) - Content heuristics (
auto_map: true) — long free-text → Open-ended; chartable data → Survey filter - Fallback — Do nothing (ignored)
Column types:
| Type | Behavior |
|---|---|
Name | Respondent name — added to participant profile |
Email | Respondent email — added to participant profile |
Timestamp | Response date/time — chronological ordering |
Open-ended | Free-text — treated as qualitative insight (creates notes) |
Single choice | Single-select — chartable survey filter |
Multi choice | Multi-select — chartable survey filter |
NPS | Net Promoter Score — chartable survey filter |
Ranking | Ranked responses — chartable survey filter |
Do nothing | Column ignored during import |
Examples:
{
"auto_map": false,
"column_mapping": {
"Full Name": "Name",
"Email": "Email",
"Feedback": "Open-ended",
"Plan Type": "Single choice"
}
}
Complete Python Example#
import time
import requests
BASE_URL = "https://app.heymarvin.com/api"
# 1. Get access token
token_resp = 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",
},
)
access_token = token_resp.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 upload
init_resp = requests.post(
f"{BASE_URL}/v1/developer/import/surveys/initialize",
headers=headers,
json={
"project_id": project_id,
"name": "Q1 Customer Interviews",
"file_type": "CSV",
"num_parts": 1,
"add_to_research_panel": True,
"column_mapping": {
"Name": "Name",
"Email": "Email",
"Date": "Timestamp",
"Feedback": "Open-ended",
"Plan": "Single choice",
},
},
)
init_data = init_resp.json()
upload_key = init_data["upload_key"]
part_info = init_data["upload_urls"][0]
# 4. Upload CSV to S3 (max 50 MB per part)
with open("survey.csv", "rb") as f:
requests.post(
part_info["upload_url"],
data=part_info["upload_fields"],
files={"file": f},
).raise_for_status()
# 5. Complete the upload
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()
state = status["status"]
print(f"Status: {state}")
if state == "COMPLETED":
print(f"Imported {status['processing']['total_responses']} responses.")
break
elif state == "FAILED":
print(f"Import failed: {status['error_details']}")
break
time.sleep(5)