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

EndpointScope
GET /api/v1/developer/import/surveyssurvey:write
POST /api/v1/developer/import/surveys/initializesurvey:write
POST {upload_url} (S3, not Marvin)
POST /api/v1/developer/import/surveys/{upload_key}/completesurvey:write
GET /api/v1/developer/import/surveys/{upload_key}/statussurvey:write

Recovery and additions

EndpointPurpose
POST .../surveys/{upload_key}/parts/{part_number}/retryFresh presigned URL for one CSV part
POST .../surveys/{upload_key}/appendAdd rows to an existing survey

Media attachments

EndpointPurpose
POST .../surveys/{upload_key}/media/registerDeclare media filenames, get presigned URLs
POST {upload_url}Upload a media file to S3
POST .../surveys/{upload_key}/media/{filename}/retryFresh 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.

bash
curl -s "$BASE_URL/api/v1/developer/import/surveys" \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Response:

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

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

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 types). Use the MEDIA_RESPONSE type to attach media at the response level.
auto_mapbooleanNoUse AI to auto-detect column types (default: false)
add_to_research_panelbooleanNoAdd respondents to Research Panel (default: true)
media_configobjectNoEnables respondent-level media linking. See linking modes.
idempotency_keystringNoUnique key to prevent duplicates (max 255 chars). Same key + same API key = returns existing upload. Same key + different API key = 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"
    }
  ],
  "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.

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. 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.

bash
curl -s -X POST "$BASE_URL/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 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:

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

bash
curl -s "$BASE_URL/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",
  "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:

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

Part status values (parts[].status):

StatusMeaning
PENDINGPresigned URL issued; file not yet uploaded to S3
UPLOADEDFile present in S3; not yet processed into the survey
PROCESSEDRows saved to the survey. Not retryable — see retry
FAILEDProcessing failed for this part; see error_details

Media file status values:

StatusMeaning
PENDINGPresigned URL issued; file not yet uploaded to S3
UPLOADEDFile present in S3; awaiting linking during processing
LINKEDFile linked to a respondent or response (media_level set)
FAILEDFile 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.

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

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.

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.

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

FieldTypeRequiredDescription
num_partsintegerYesNumber 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:

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

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

FieldTypeRequiredDescription
filesarray of stringsYesMedia filenames to register (max 512 chars each). Must be unique within the request.

Response (201):

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

CategoryExtensionsTranscribed
Videomp4, mov, avi, m4v, webmYes
Audiomp3, m4a, wav, aacYes

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.

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

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

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

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 response — chartable survey filter
MEDIA_RESPONSEEach cell holds a media filename — links the uploaded file to that response
Do nothingColumn ignored during import

Resolution priority when column_mapping and auto_map are combined:

  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). Only detects Name, Email, Timestamp, and Open-ended; it never assigns MEDIA_RESPONSE.
  3. Content heuristics (auto_map: true) — long free-text → Open-ended; chartable data → Survey filter
  4. 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.