CSVs
Endpoints for importing tabular data as CSV. For walkthroughs with runnable code, see Import CSV, Import unstructured data, Import surveys, or Import multimedia CSV — all four use these endpoints.
Core flow
| Endpoint | Scope |
|---|---|
GET /api/v1/developer/import/surveys | survey:write |
POST /api/v1/developer/import/surveys/initialize | survey:write |
POST {upload_url} (S3, not Marvin) | — |
POST /api/v1/developer/import/surveys/{upload_key}/complete | survey:write |
GET /api/v1/developer/import/surveys/{upload_key}/status | survey:write |
Recovery and additions
| Endpoint | Purpose |
|---|---|
POST .../surveys/{upload_key}/parts/{part_number}/retry | Fresh presigned URL for one CSV part |
POST .../surveys/{upload_key}/append | Add rows to an existing survey |
Media attachments
| Endpoint | Purpose |
|---|---|
POST .../surveys/{upload_key}/media/register | Declare media filenames, get presigned URLs |
POST {upload_url} | Upload a media file to S3 |
POST .../surveys/{upload_key}/media/{filename}/retry | Fresh presigned URL for one media file |
There is no separate read-only scope for surveys in this API — survey:write covers reads too. You'll also need GET /import/projects (project:read) to resolve a project_id. Marvin endpoints here are rate limited to 20 requests/minute per API key.
GET /api/v1/developer/import/surveys #
Lists surveys previously imported through this API that reached COMPLETED.
curl -s "$BASE_URL/api/v1/developer/import/surveys" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response:
{
"surveys": [
{
"upload_key": "e5f6a7b8-...",
"survey_key": "a1b2c3d4-...",
"survey_name": "Q1 Customer Interviews",
"project_id": 42,
"number_of_responses": 250,
"num_parts": 1,
"completed_at": "2026-04-01T00:01:30Z"
}
]
}
Only lists uploads scoped to projects accessible to your API key. Use upload_key from this response with append.
POST /api/v1/developer/import/surveys/initialize #
Creates a survey upload and returns presigned S3 URLs for each CSV part.
curl -s -X POST "$BASE_URL/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 types). Use the MEDIA_RESPONSE type to attach media at the response level. |
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) |
media_config | object | No | Enables respondent-level media linking. See linking modes. |
idempotency_key | string | No | Unique key to prevent duplicates (max 255 chars). Same key + same API key = returns existing upload. Same key + different API key = 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"
}
],
"add_to_research_panel": true,
"media_config": null,
"links": {
"complete": "/api/v1/developer/import/surveys/e5f6a7b8-.../complete",
"status": "/api/v1/developer/import/surveys/e5f6a7b8-.../status",
"register_media": null
}
}
media_config echoes back the respondent-level media config you supplied (or null). links.register_media is populated only when the upload has media — i.e. you passed a media_config or mapped at least one column as MEDIA_RESPONSE; otherwise it is null.
POST {upload_url} (S3) #
Upload each part directly to S3 via the presigned POST from initialize. 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. Each part is processed independently and sequentially (parts are not merged) — each part may contain up to 20,000 rows; there is no cap on the total across parts. Presigned URLs expire after 1 hour — use the retry endpoint to get fresh URLs.
POST /api/v1/developer/import/surveys/{upload_key}/complete #
Signals Marvin to verify uploads and start processing the survey.
curl -s -X POST "$BASE_URL/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 CSV parts and all registered media files exist in S3 synchronously before enqueueing processing. If anything is missing, the call returns 400 instead of starting:
{
"error": "Some files have not been uploaded.",
"missing_parts": [2, 3],
"missing_media_files": ["intro__alice.mp4"]
}
missing_parts and missing_media_files are each included only when that category has missing files. Re-upload the missing files (use the appropriate retry endpoint for fresh URLs), then call complete again.
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.
Partial failure: Parts are processed sequentially and independently. If part 3 of a 5-part batch fails validation (e.g. a missing required column on an append, or a part over the row limit), parts 1–2's data is already saved to the survey and is not rolled back; parts 4–5 are left untouched. status becomes FAILED and error_details names the specific part. Use the retry endpoint on just the failed part number, then call complete again — already-processed parts are skipped automatically.
GET /api/v1/developer/import/surveys/{upload_key}/status #
Returns the current processing status for the survey upload. Poll until status is COMPLETED or FAILED.
curl -s "$BASE_URL/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",
"media_files": [
{ "filename": "intro__alice.mp4", "status": "LINKED", "media_level": "RESPONDENT" },
{ "filename": "clip-123.mp4", "status": "LINKED", "media_level": "RESPONSE" }
]
}
media_files is null for uploads without media. When present, each entry reports the per-file linking outcome. A file ends as FAILED if no linker could match it (unsupported extension, filename that matches no respondent or response, etc.) — the overall upload can still COMPLETE while individual media files fail.
Upload status values:
| Status | Meaning |
|---|---|
AWAITING_UPLOAD | Parts not yet uploaded to S3 |
VALIDATING | Verifying parts exist in S3 |
PROCESSING | Survey being parsed and mapped |
COMPLETED | Import finished — survey available in Marvin |
FAILED | Import failed — see error_details |
Part status values (parts[].status):
| Status | Meaning |
|---|---|
PENDING | Presigned URL issued; file not yet uploaded to S3 |
UPLOADED | File present in S3; not yet processed into the survey |
PROCESSED | Rows saved to the survey. Not retryable — see retry |
FAILED | Processing failed for this part; see error_details |
Media file status values:
| Status | Meaning |
|---|---|
PENDING | Presigned URL issued; file not yet uploaded to S3 |
UPLOADED | File present in S3; awaiting linking during processing |
LINKED | File linked to a respondent or response (media_level set) |
FAILED | File could not be linked to any respondent or response |
POST /api/v1/developer/import/surveys/{upload_key}/parts/{part_number}/retry #
Re-issues a fresh presigned URL for a CSV part.
curl -s -X POST \
"$BASE_URL/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.
Only retry the part number named in error_details — a part that already finished processing (status: PROCESSED, see status) returns 400 if you try to retry it, since its rows are already saved to the survey and re-uploading it would duplicate them.
POST /api/v1/developer/import/surveys/{upload_key}/append #
Add more rows to a survey previously imported through this API. Reuses the column_mapping/media_config/auto_map from the original import — these cannot be changed on append.
curl -s -X POST "$BASE_URL/api/v1/developer/import/surveys/$UPLOAD_KEY/append" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "num_parts": 1 }' | python3 -m json.tool
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
num_parts | integer | Yes | Number of new CSV parts to upload (1–100) |
Only allowed when the upload's status is COMPLETED (400 otherwise). Returns 404 if the upload_key doesn't exist or isn't accessible to your API key, or if the upload's project is no longer accessible. Appended parts must contain a header row with all of the existing survey's question columns (extra/unrecognized columns are ignored, not imported). A part missing a required column fails that part — see the partial-failure note on complete. Two calls that race to append to the same upload at the same time will return 409 Conflict for the loser — retry.
Response (201): same shape as initialize, scoped to just the new parts:
{
"survey_key": "a1b2c3d4-...",
"upload_key": "e5f6a7b8-...",
"status": "AWAITING_UPLOAD",
"num_parts": 2,
"upload_urls": [
{
"part_number": 2,
"upload_url": "https://s3.amazonaws.com/bucket/",
"upload_fields": { "...": "..." },
"s3_key": "developer-uploads/10/e5f6a7b8-.../part-2.csv"
}
],
"add_to_research_panel": true,
"media_config": null,
"links": {
"complete": "/api/v1/developer/import/surveys/e5f6a7b8-.../complete",
"status": "/api/v1/developer/import/surveys/e5f6a7b8-.../status",
"register_media": null
}
}
Upload the new part(s) to S3 (and, if the upload has media, register and upload new media files the same way), then call complete again — it will only process the newly uploaded parts.
POST /api/v1/developer/import/surveys/{upload_key}/media/register #
After initialize, declare your media filenames to get a presigned S3 URL for each. Only available when the upload has media (a media_config or a MEDIA_RESPONSE column), and only while status is AWAITING_UPLOAD or FAILED.
curl -s -X POST \
"$BASE_URL/api/v1/developer/import/surveys/$UPLOAD_KEY/media/register" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "files": ["enterprise__alice.mp4", "smb__bob.mp4"] }' | python3 -m json.tool
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
files | array of strings | Yes | Media filenames to register (max 512 chars each). Must be unique within the request. |
Response (201):
{
"media_files": [
{
"filename": "enterprise__alice.mp4",
"upload_url": "https://s3.amazonaws.com/bucket/",
"upload_fields": {
"key": "developer-uploads/10/e5f6a7b8-.../media/enterprise__alice.mp4",
"x-amz-credential": "...",
"policy": "...",
"x-amz-signature": "..."
}
}
]
}
You can call this endpoint multiple times to register additional files. Filenames already registered are skipped, except files still in PENDING status, for which a fresh URL is re-issued. Unlike CSV parts, the media presigned POST enforces no Content-Type condition — S3 accepts the file as-is.
Supported media types:
| Category | Extensions | Transcribed |
|---|---|---|
| Video | mp4, mov, avi, m4v, webm | Yes |
| Audio | mp3, m4a, wav, aac | Yes |
Files with any other extension are marked FAILED during linking.
POST {upload_url} (S3 media) #
Upload each file directly to S3 via the media presigned POST — send all upload_fields as form fields before the file. Unlike CSV parts, there is no per-file size limit on media uploads.
curl -X POST "https://s3.amazonaws.com/bucket/" \
-F "key=developer-uploads/10/e5f6a7b8-.../media/enterprise__alice.mp4" \
-F "x-amz-credential=..." \
-F "policy=..." \
-F "x-amz-signature=..." \
-F "file=@enterprise__alice.mp4"
Presigned URLs expire after 1 hour — use the media retry endpoint for fresh URLs. After all media files are uploaded, call complete; it verifies every registered media file exists in S3 (see missing_media_files).
POST /api/v1/developer/import/surveys/{upload_key}/media/{filename}/retry #
Re-issue a presigned URL for a single media file. Allowed only when the file is in PENDING or FAILED status (already-UPLOADED/LINKED files return 400). Resets the file to PENDING.
curl -s -X POST \
"$BASE_URL/api/v1/developer/import/surveys/$UPLOAD_KEY/media/enterprise__alice.mp4/retry" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response (200):
{
"filename": "enterprise__alice.mp4",
"upload_url": "https://s3.amazonaws.com/bucket/",
"upload_fields": {
"key": "developer-uploads/10/e5f6a7b8-.../media/enterprise__alice.mp4",
"x-amz-credential": "...",
"policy": "...",
"x-amz-signature": "..."
}
}
Column types #
Values accepted by column_mapping on initialize.
| 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 response — chartable survey filter |
MEDIA_RESPONSE | Each cell holds a media filename — links the uploaded file to that response |
Do nothing | Column ignored during import |
Resolution priority when column_mapping and auto_map are combined:
- Explicit
column_mapping— column header matches a key → that type is used - AI recommendation (
auto_map: true) — classifier suggests a type (≥90% confidence). Only detects Name, Email, Timestamp, and Open-ended; it never assignsMEDIA_RESPONSE. - Content heuristics (
auto_map: true) — long free-text → Open-ended; chartable data → Survey filter - Fallback — Do nothing (ignored)
Explicit mappings always override AI detection. MEDIA_RESPONSE is only ever applied when you set it explicitly — auto_map will not infer it.
See choosing a mapping strategy for worked examples.