Content Upload API¶
The content upload pipeline lets users upload bundles of text and files. Files are uploaded directly to S3 via presigned URLs — the backend never proxies file data.
All endpoints require authentication.
Upload Flow¶
sequenceDiagram
participant C as Client
participant B as Backend
participant S3 as S3
participant CF as CloudFront
C->>B: POST /api/raw-items/upload
Note right of B: Create files (pending)
B-->>C: itemId + presigned URLs
loop For each attachment
C->>S3: POST multipart form
S3-->>C: 204 + ETag + SHA256
end
C->>B: POST /api/raw-items/id/confirm
B->>S3: HeadObject
S3-->>B: size, etag, sha256
Note right of B: Update files and item (ready)
B-->>C: status ready
C->>B: GET /api/files/fileId/download
B-->>C: 307 Redirect
C->>CF: GET signed URL
CF->>S3: Fetch object (OAC)
S3-->>CF: File data
CF-->>C: File data
Endpoints¶
POST /api/raw-items/upload¶
Request an upload. The backend creates database records and returns presigned URLs for each attachment.
Request:
{
"type": "PORTAL_UPLOAD_BUNDLE",
"mainText": "Today's notes with some attachments",
"attachments": [
{
"filename": "photo.jpg",
"contentType": "image/jpeg",
"sizeBytes": 2048000
},
{
"filename": "report.pdf",
"contentType": "application/pdf",
"sizeBytes": 512000
},
{
"type": "link",
"url": "https://example.com/interesting-article"
}
]
}
Response (201):
{
"itemId": "019e4596-2b4a-7c03-9b7f-7c6939c9564f",
"attachments": [
{
"index": 0,
"url": "https://inklet-dev.s3.us-east-1.amazonaws.com",
"fields": {
"key": "user/1a16f2b9-.../2026/05/019e4596-2b44-....jpg",
"Content-Type": "image/jpeg",
"x-amz-checksum-algorithm": "SHA256",
"x-amz-credential": "AKIA.../20260520/us-east-1/s3/aws4_request",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-date": "20260520T130000Z",
"policy": "eyJleHBpcm...",
"x-amz-signature": "a1b2c3d4..."
}
},
{
"index": 1,
"url": "https://inklet-dev.s3.us-east-1.amazonaws.com",
"fields": { "..." }
}
],
"expiresAt": "2026-05-20T13:47:09Z"
}
Uploading to S3
The client must construct a multipart/form-data POST request to the url. Include all fields as form fields first, then append the file as the last field named file.
Expiration
The presigned URLs expire after 15 minutes (indicated by expiresAt). Upload all files before this time.
Errors:
| Status | Reason |
|---|---|
| 400 | Missing type, unsupported type, or invalid content type |
| 413 | File exceeds 10 MB size limit |
Allowed content types (for file attachments):
image/png, image/jpeg, image/gif, image/webp, image/svg+xml, application/pdf, text/plain, text/markdown, application/json
Link Attachments
Attachments with type: "link" don't require S3 upload. The URL is stored directly in the content JSON. During summary processing, the worker fetches the link content via Jina Reader and stores the raw markdown in content.attach[].raw_content. The LLM summary is stored in summary_content.attachments[].description (without raw_content).
POST /api/raw-items/{id}/confirm¶
Confirm that all files have been uploaded. The backend verifies each file exists in S3 via HeadObject, retrieves the SHA256 checksum, and updates the database.
Response (200) — all files confirmed:
Response (200) — some files failed:
The failed array contains the indices of attachments that could not be verified. The client can retry uploading those files and call confirm again.
Errors:
| Status | Reason |
|---|---|
| 404 | Item not found |
| 403 | Not the item owner |
| 409 | Item is not in pending status |
GET /api/raw-items¶
List the authenticated user's items with pagination. Results are ordered by creation time (newest first). The response does not include content or userId fields.
Query parameters:
| Parameter | Description | Default |
|---|---|---|
status |
Filter by status: pending, ready, partial |
all |
page |
Page number (1-based) | 1 |
limit |
Items per page (max 50) | 20 |
Response (200):
{
"items": [
{
"id": "019e4596-2b4a-...",
"type": "PORTAL_UPLOAD_BUNDLE",
"format": "v0",
"status": "ready",
"processStatus": "RAW",
"createdAt": "2026-05-20T13:32:09Z",
"updatedAt": "2026-05-20T13:32:15Z"
}
],
"total": 42,
"page": 1,
"limit": 20
}
GET /api/raw-items/{id}¶
Get a single item. Returns 403 if the item belongs to another user.
GET /api/raw-items/stats¶
Weekly processing stats for the authenticated user — counts of raw items by processStatus over the last 7 days.
Response (200):
| Field | Description |
|---|---|
since |
Start of the 7-day window (RFC3339) |
RAW |
Items not yet ingested |
INGESTED |
Items decomposed into content blocks, processing |
READY |
Items whose blocks all summarized |
FAILED |
Items where at least one content block failed |
GET /api/files/{fileId}/download¶
Returns a 307 redirect to a CloudFront signed URL (expires in 15 minutes). The client follows the redirect to download the file.
Only the file owner can access this endpoint. The file must be in ready status.
Errors:
| Status | Reason |
|---|---|
| 404 | File not found |
| 403 | Not the file owner |
| 409 | File is not ready |
S3 Object Key Format¶
user/{userId}/{year}/{month}/{fileId}.{ext}
Example:
user/1a16f2b9-3b9d-4e57-9272-6a03f0f2274e/2026/05/019e4596-2b44-7c2b-8e36-d367ecab52b7.txt
Content JSON Schema¶
The content field of a PORTAL_UPLOAD_BUNDLE item (format v0):
{
"type": "PORTAL_UPLOAD_BUNDLE",
"format": "v0",
"upload_at": "2026-05-20T13:32:09Z",
"main_text": "The user's text content",
"attach": [
{
"type": "image",
"mime": "image/jpeg",
"file_id": "019e4596-2b44-7c2b-8e36-d367ecab52b7"
},
{
"type": "doc",
"mime": "application/pdf",
"file_id": "019e4596-3c55-7d3c-9f47-e478fdb63c8a"
},
{
"type": "link",
"url": "https://example.com/article",
"raw_content": "# Article Title\n\nFull markdown content fetched by Jina Reader..."
}
]
}
Attachment types:
| Type | Fields | Description |
|---|---|---|
image |
mime, file_id |
Image file uploaded to S3 |
doc |
mime, file_id |
Document file uploaded to S3 |
link |
url, raw_content |
URL link. raw_content is added by the summary worker after fetching via Jina Reader |
File Lifecycle¶
pending → ready (normal: upload + confirm)
pending → failed (cleanup: no upload after 1 hour)
ready → deleted (user deletes)
deleted → purged (cleanup: S3 object removed after 7 days)
Process Status¶
After an item's upload status becomes ready, the processStatus field tracks the content pipeline:
| processStatus | Description |
|---|---|
RAW |
Just uploaded, not yet ingested |
INGESTED |
Decomposed into content blocks; download/summary in progress |
READY |
All content blocks summarized |
FAILED |
At least one content block failed |
The transition is RAW → INGESTED → READY. If any content block fails (download or summary), the item is set to FAILED.