File Uploads
Upload, manage, and reference files on Surflet pages
Surflet supports attaching files to pages — images, PDFs, documents, or any binary asset. The upload flow uses a presigned URL pattern: first request an upload slot, PUT the file bytes directly to the storage URL, then commit the record. The download URL is served via a redirect, so no API key is needed for viewers.
Upload Flow Overview
1. POST /v1/pages/:pageId/files/presign → get { uploadUrl, storageKey }
2. PUT <uploadUrl> → stream file bytes
3. POST /v1/pages/:pageId/files/complete → commit record, get file object
Step 1 — Request a Presigned Upload URL
curl -X POST https://api.surflet.app/v1/pages/pg_abc123/files/presign \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"filename": "invoice-4821.pdf",
"mime_type": "application/pdf",
"size_bytes": 524288,
"uploaded_by": "agent:refund-bot",
"upload_type": "agent"
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | Original filename |
mime_type | string | yes | MIME type (e.g. image/jpeg, application/pdf) |
size_bytes | number | no | File size in bytes (informational) |
uploaded_by | string | no | Uploader identity label |
upload_type | string | no | agent or user (defaults to agent when authenticated) |
Response (200):
{
"uploadUrl": "/v1/storage/upload/tenant_id%2Fpg_abc123%2F1713000000000_invoice-4821.pdf",
"storageKey": "tenant_id/pg_abc123/1713000000000_invoice-4821.pdf",
"filename": "invoice-4821.pdf",
"mimeType": "application/pdf",
"uploadedBy": "agent:refund-bot",
"uploadType": "agent"
}
The storageKey uniquely identifies the file in storage. Save it — you need it for Step 3.
Authentication is optional. Public pages allow anonymous uploads. Authenticated requests stamp the uploader identity automatically.
Step 2 — PUT the File Bytes
Use the uploadUrl returned in Step 1. Stream the file body directly — no JSON wrapping.
curl -X PUT https://api.surflet.app/v1/storage/upload/tenant_id%2Fpg_abc123%2F1713000000000_invoice-4821.pdf \
-H "Content-Type: application/pdf" \
--data-binary @invoice-4821.pdf
Response: 200 with empty body on success.
The server accepts any Content-Type. The raw request stream is written to disk. No authentication is required on this endpoint — the presigned key acts as the token.
Step 3 — Commit the Upload
After the PUT succeeds, call complete to register the file record in the database and publish a file.uploaded event to page subscribers.
curl -X POST https://api.surflet.app/v1/pages/pg_abc123/files/complete \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"storage_key": "tenant_id/pg_abc123/1713000000000_invoice-4821.pdf",
"filename": "invoice-4821.pdf",
"mime_type": "application/pdf",
"uploaded_by": "agent:refund-bot",
"upload_type": "agent"
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
storage_key | string | yes | The storageKey from Step 1 |
filename | string | yes | Original filename |
mime_type | string | yes | MIME type |
uploaded_by | string | no | Uploader identity label |
upload_type | string | no | agent or user |
Response (201):
{
"id": "file_abc123",
"pageId": "pg_abc123",
"tenantId": "tenant_id",
"filename": "invoice-4821.pdf",
"mimeType": "application/pdf",
"sizeBytes": 524288,
"storageKey": "tenant_id/pg_abc123/1713000000000_invoice-4821.pdf",
"uploadedBy": "agent:refund-bot",
"uploadType": "agent",
"createdAt": "2026-04-15T10:00:00Z"
}
Error (404): If the storage_key does not match an uploaded file (i.e., Step 2 was skipped or failed):
{ "error": "FILE_NOT_FOUND", "message": "File is not uploaded" }
List Files on a Page
curl https://api.surflet.app/v1/pages/pg_abc123/files \
-H "Authorization: Bearer sk_live_..."
Response (200):
{
"files": [
{
"id": "file_abc123",
"filename": "invoice-4821.pdf",
"mimeType": "application/pdf",
"sizeBytes": 524288,
"uploadedBy": "agent:refund-bot",
"uploadType": "agent",
"createdAt": "2026-04-15T10:00:00Z"
}
]
}
No authentication required — this endpoint is publicly accessible on public pages.
Download a File
curl -L https://api.surflet.app/v1/pages/pg_abc123/files/file_abc123/download
The endpoint redirects to the presigned download URL. Use -L (follow redirect) or handle the redirect in your HTTP client. The redirect target serves the raw file bytes.
Error (404): File does not exist or does not belong to the specified page.
Delete a File
curl -X DELETE https://api.surflet.app/v1/pages/pg_abc123/files/file_abc123 \
-H "Authorization: Bearer sk_live_..."
Response: 204 No Content.
Deletion removes both the database record and the file from disk. A file.deleted event is published to the page's SSE stream. The file ID is no longer valid after deletion.
Using File URLs in Blocks
After uploading a file, use the download URL as the url field in image, gallery, evidence_gallery, or file blocks. Construct the URL from the file ID:
https://api.surflet.app/v1/pages/<pageId>/files/<fileId>/download
image block
{
"type": "image",
"data": {
"title": "Damage Evidence",
"url": "https://api.surflet.app/v1/pages/pg_abc123/files/file_abc123/download",
"alt": "Product damage photo",
"caption": "Front casing crack"
}
}
gallery block
{
"type": "gallery",
"data": {
"title": "Evidence Photos",
"items": [
{
"url": "https://api.surflet.app/v1/pages/pg_abc123/files/file_abc123/download",
"caption": "Front view",
"mime_type": "image/jpeg"
},
{
"url": "https://api.surflet.app/v1/pages/pg_abc123/files/file_def456/download",
"caption": "Side view",
"mime_type": "image/jpeg"
}
],
"layout": "grid"
}
}
evidence_gallery block
{
"type": "evidence_gallery",
"data": {
"title": "Submitted Evidence",
"items": [
{
"type": "image",
"url": "https://api.surflet.app/v1/pages/pg_abc123/files/file_abc123/download",
"caption": "Damage photo",
"uploaded_by": "customer"
},
{
"type": "document",
"url": "https://api.surflet.app/v1/pages/pg_abc123/files/file_xyz789/download",
"caption": "Original receipt"
}
]
}
}
file block
{
"type": "file",
"data": {
"title": "Attachments",
"files": [
{
"name": "invoice-4821.pdf",
"url": "https://api.surflet.app/v1/pages/pg_abc123/files/file_abc123/download",
"mime_type": "application/pdf",
"size_bytes": 524288
}
]
}
}
Full Upload Example (Python)
import httpx
API_KEY = "sk_live_..."
BASE_URL = "https://api.surflet.app"
PAGE_ID = "pg_abc123"
headers = {"Authorization": f"Bearer {API_KEY}"}
# Step 1: presign
presign = httpx.post(
f"{BASE_URL}/v1/pages/{PAGE_ID}/files/presign",
headers=headers,
json={
"filename": "report.pdf",
"mime_type": "application/pdf",
"uploaded_by": "my-agent",
"upload_type": "agent",
},
).raise_for_status().json()
# Step 2: PUT file bytes
with open("report.pdf", "rb") as f:
httpx.put(
f"{BASE_URL}{presign['uploadUrl']}",
content=f.read(),
headers={"Content-Type": "application/pdf"},
).raise_for_status()
# Step 3: commit
file_record = httpx.post(
f"{BASE_URL}/v1/pages/{PAGE_ID}/files/complete",
headers=headers,
json={
"storage_key": presign["storageKey"],
"filename": "report.pdf",
"mime_type": "application/pdf",
"uploaded_by": "my-agent",
"upload_type": "agent",
},
).raise_for_status().json()
print(f"File ID: {file_record['id']}")
download_url = f"{BASE_URL}/v1/pages/{PAGE_ID}/files/{file_record['id']}/download"
print(f"Download URL: {download_url}")
SSE Events Triggered by File Operations
File operations publish events to the page's SSE stream (GET /v1/pages/:pageId/events):
| Event type | Triggered by |
|---|---|
file.uploaded | POST /complete — file successfully committed |
file.deleted | DELETE /files/:fileId — file removed |
Example event payload:
data: {"type":"file.uploaded","data":{"fileId":"file_abc123","filename":"invoice-4821.pdf","uploadedBy":"agent:refund-bot"}}