Developer API Guide — Insight Import
Create an Insight in Marvin by uploading a document — PDF, PPT/PPTX, DOC/DOCX, XLS/XLSX, or TXT — programmatically via API keys and presigned S3 uploads. This mirrors what happens when a user drags a file into the Insights tab: Marvin converts the document (via Google Drive) into a viewable PDF, extracts a thumbnail, and — once you signal completion — kicks off the same background processing the web app uses.
This is a separate resource type from surveys. Insights are not CSV data; each upload is a single document. See the Survey Import API Guide for importing survey response data.
Prerequisites#
Create an API key with project:read + insight:write scopes and exchange it for a JWT. See API Key Integration Guide for the full auth setup.
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 insight:write" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
The token expires in 1 hour. Exchange again when it expires.
Optional: include project:write if you need to create a project via the API.
Import Flow#
1. List projects → pick a project_id
2. Initialize → get a presigned S3 upload URL
3. Upload to S3 → POST the document directly to S3
4. Complete → signal Marvin to start processing
5. Poll status → wait for "completed"
There's no chunking (one document = one upload) and no idempotency key — retrying initialize creates a new Insight each time.
GET /api/v1/developer/import/projects #
Returns projects accessible to your API key. Requires project:read scope. Archived and deleted projects are excluded.
curl -s "$BASE_URL/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" }
]
}
Team-shared keys see team projects; personal keys see user-accessible projects.
POST /api/v1/developer/import/insights/initialize #
Creates an insight upload and returns a presigned S3 URL. Requires insight:write scope.
curl -s -X POST "$BASE_URL/api/v1/developer/import/insights/initialize" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_id": 42,
"filename": "q1-research-summary.pdf"
}' | python3 -m json.tool
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
project_id | integer | Yes | ID from the list projects response |
filename | string | Yes | Original filename, used to determine the document type. Extension must be one of pdf, ppt, pptx, doc, docx, xls, xlsx, txt |
should_publish is specified on complete, not here.
Response (201):
{
"insight_key": "e5f6a7b8-...",
"upload_url": "https://s3.amazonaws.com/bucket/",
"upload_fields": {
"key": "developer-uploads/10/e5f6a7b8-.../doc.pdf",
"Content-Type": "application/pdf",
"x-amz-credential": "...",
"policy": "...",
"x-amz-signature": "..."
},
"links": {
"complete": "/api/v1/developer/import/insights/e5f6a7b8-.../complete",
"status": "/api/v1/developer/import/insights/e5f6a7b8-.../status"
}
}
Rate limited to 20 requests/minute per API key.
POST {upload_url} (S3) #
Upload the document directly to S3 via the presigned POST from initialize. All upload_fields must be sent as form fields before the file. The document is capped at 500 MB.
curl -X POST "https://s3.amazonaws.com/bucket/" \
-F "key=developer-uploads/10/e5f6a7b8-.../doc.pdf" \
-F "Content-Type=application/pdf" \
-F "x-amz-credential=..." \
-F "policy=..." \
-F "x-amz-signature=..." \
-F "file=@q1-research-summary.pdf"
Presigned URLs expire after 1 hour.
POST /api/v1/developer/import/insights/{insight_key}/complete #
Signals Marvin that the S3 upload finished and starts document processing.
curl -s -X POST "$BASE_URL/api/v1/developer/import/insights/$INSIGHT_KEY/complete" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"should_publish": false
}' | python3 -m json.tool
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
should_publish | boolean | No | Publish the insight immediately once processing finishes (default: false, insight stays a private draft) |
The document type and S3 key are derived from the filename you gave at initialize time — you don't resend it here. This avoids a mismatch if a different filename were passed to complete than was used to compute the upload's S3 key.
Response (202):
{
"insight_key": "e5f6a7b8-...",
"status": "processing"
}
If the document hasn't finished uploading to S3 yet, this returns 400:
{
"error": "File not found in S3. Upload the file before calling complete."
}
Idempotency: Calling this endpoint again once the insight has reached completed or failed returns 200 with the current status instead of re-triggering processing. A duplicate call that races the original request while it's still processing (processing_status stays pending for the whole duration) is also caught — the row is locked for the duration of the request, so the duplicate reliably sees the in-progress document and returns 200 with "message": "Upload is already processing; see status endpoint." instead of enqueueing a second job.
GET /api/v1/developer/import/insights/{insight_key}/status #
Returns the current processing status for the insight upload. Poll until processing_status is completed or failed.
curl -s "$BASE_URL/api/v1/developer/import/insights/$INSIGHT_KEY/status" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Response:
{
"insight_key": "e5f6a7b8-...",
"name": "q1-research-summary.pdf",
"processing_status": "completed",
"insight_state": 1,
"insight_type": "pdf",
"thumbnail_image": "https://s3.amazonaws.com/bucket/insights/thumbnail/....png",
"created_at": "2026-07-10T00:00:00Z"
}
processing_status values:
| Status | Meaning |
|---|---|
pending | Document not yet processed — awaiting complete or processing is in progress |
completed | Conversion finished — insight available in Marvin |
failed | Processing failed |
insight_state values:
| Value | Meaning |
|---|---|
0 | Published — visible to the team |
1 | Private draft — visible only to the creator |
2 | Shared draft |
An insight starts as a private draft (1) at initialize time; once processing completes it becomes 0 (published) if should_publish was true, or stays 1 otherwise.
insight_type values: pdf, ppt, sheet, doc, marvin_doc, other — derived from the uploaded file's extension.
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 insight: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 the insight upload
filename = "q1-research-summary.pdf"
init_resp = requests.post(
f"{BASE_URL}/v1/developer/import/insights/initialize",
headers=headers,
json={"project_id": project_id, "filename": filename},
)
init_data = init_resp.json()
insight_key = init_data["insight_key"]
# 4. Upload the document to S3 (max 500 MB)
with open(filename, "rb") as f:
requests.post(
init_data["upload_url"],
data=init_data["upload_fields"],
files={"file": f},
).raise_for_status()
# 5. Complete the upload
requests.post(
f"{BASE_URL}/v1/developer/import/insights/{insight_key}/complete",
headers=headers,
json={"should_publish": False},
).raise_for_status()
# 6. Poll until done
while True:
status = requests.get(
f"{BASE_URL}/v1/developer/import/insights/{insight_key}/status",
headers=headers,
).json()
state = status["processing_status"]
print(f"Status: {state}")
if state == "completed":
print(f"Insight ready: {status['insight_key']}")
break
elif state == "failed":
print("Insight processing failed.")
break
time.sleep(5)