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)

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

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. List Projects#

bash
curl -s https://app.heymarvin.com/api/v1/developer/import/projects \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Response:

json
{
  "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#

bash
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:

FieldTypeRequiredDescription
project_idintegerYesID from the list projects response
namestringYesSurvey name (max 200 chars)
file_typestringYesMust be "CSV"
num_partsintegerYesNumber of CSV parts to upload (1–100)
column_mappingobjectNoMap CSV column names to types (see Column Mapping)
auto_mapbooleanNoUse AI to auto-detect column types (default: false)
add_to_research_panelbooleanNoAdd respondents to Research Panel (default: true)
idempotency_keystringNoUnique 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):

json
{
  "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.

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

bash
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):

json
{
  "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:

json
{
  "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#

bash
curl -s \
  https://app.heymarvin.com/api/v1/developer/import/surveys/$UPLOAD_KEY/status \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Response:

json
{
  "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:

StatusMeaning
AWAITING_UPLOADParts not yet uploaded to S3
VALIDATINGVerifying parts and merging CSV
PROCESSINGSurvey being parsed and mapped
COMPLETEDImport finished — survey available in Marvin
FAILEDImport failed — see error_details

6. Retry a Failed Part#

bash
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):

json
{
  "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:

  1. Explicit column_mapping — column header matches a key → that type is used
  2. AI recommendation (auto_map: true) — classifier suggests a type (≥90% confidence)
  3. Content heuristics (auto_map: true) — long free-text → Open-ended; chartable data → Survey filter
  4. Fallback — Do nothing (ignored)

Column types:

TypeBehavior
NameRespondent name — added to participant profile
EmailRespondent email — added to participant profile
TimestampResponse date/time — chronological ordering
Open-endedFree-text — treated as qualitative insight (creates notes)
Single choiceSingle-select — chartable survey filter
Multi choiceMulti-select — chartable survey filter
NPSNet Promoter Score — chartable survey filter
RankingRanked responses — chartable survey filter
Do nothingColumn ignored during import

Examples:

json
{
  "auto_map": false,
  "column_mapping": {
    "Full Name": "Name",
    "Email": "Email",
    "Feedback": "Open-ended",
    "Plan Type": "Single choice"
  }
}

Complete Python Example#

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