Surflet

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:

FieldTypeRequiredDescription
filenamestringyesOriginal filename
mime_typestringyesMIME type (e.g. image/jpeg, application/pdf)
size_bytesnumbernoFile size in bytes (informational)
uploaded_bystringnoUploader identity label
upload_typestringnoagent 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:

FieldTypeRequiredDescription
storage_keystringyesThe storageKey from Step 1
filenamestringyesOriginal filename
mime_typestringyesMIME type
uploaded_bystringnoUploader identity label
upload_typestringnoagent 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"
  }
}
{
  "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"
  }
}
{
  "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 typeTriggered by
file.uploadedPOST /complete — file successfully committed
file.deletedDELETE /files/:fileId — file removed

Example event payload:

data: {"type":"file.uploaded","data":{"fileId":"file_abc123","filename":"invoice-4821.pdf","uploadedBy":"agent:refund-bot"}}