# File uploads

Notion's file upload lifecycle: open an upload, send its bytes and attach it to a block, icon, cover or files property, then read the file back from the simulator.

File uploads let a test attach a file to a page the way Notion's API does: open an upload, send its bytes, then name the upload where the file belongs. The simulator keeps the bytes and serves them itself, so the whole round trip stays on your machine:

```bash
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"notion_team_wiki"}'
curl -s "$RYSTIC_URL/v1/file_uploads" -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' -H 'Content-Type: application/json' -d '{"filename":"notes.txt","content_type":"text/plain"}' | jq -c '{id, status}'
# -> {"id":"46dc3409-450d-40f1-8000-000000000001","status":"pending"}
printf 'Brand notes\n' | curl -s "$RYSTIC_URL/v1/file_uploads/46dc3409-450d-40f1-8000-000000000001/send" -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' -F 'file=@-;filename=notes.txt;type=text/plain' | jq -c '{status, filename, content_length}'
# -> {"status":"uploaded","filename":"notes.txt","content_length":12}
curl -s -X PATCH "$RYSTIC_URL/v1/blocks/a1000000-0000-4000-8000-000000000101/children" -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' -H 'Content-Type: application/json' -d '{"children":[{"type":"file","file":{"type":"file_upload","file_upload":{"id":"46dc3409-450d-40f1-8000-000000000001"}}}]}' | jq -r '.results[0].file.file.url' | xargs curl -s
# -> Brand notes
curl -s -X POST "$RYSTIC_URL/v1/file_uploads/46dc3409-450d-40f1-8000-000000000001/complete" -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' | jq -c '{status, code}'
# -> {"status":400,"code":"validation_error"}
curl -s "$RYSTIC_URL/v1/file_uploads" -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' -H 'Content-Type: application/json' -d '{"mode":"external_url","external_url":"https://example.com/brand.pdf","filename":"brand.pdf"}' | jq -c '{status}'
# -> {"status":"pending"}
curl -s "$RYSTIC_URL/v1/file_uploads/46dc3409-450d-40f1-8000-000000000002" -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' | jq -c '{status}'
# -> {"status":"uploaded"}
```

### The upload lifecycle

`POST /v1/file_uploads` returns a `pending` upload with an `upload_url` that points at the simulator. A `single_part` send carries the file's bytes as `multipart/form-data` and leaves the upload `uploaded`.

Once an upload is `uploaded`, its response and every later read leave out `upload_url` and `complete_url`, since Notion includes them only while an upload is pending.

`POST /v1/file_uploads/{file_upload_id}/complete` completes only a `multi_part` upload. Completing a `single_part` upload returns 400 `validation_error`, "One or more parts must be uploaded before completing the file upload".

`GET /v1/file_uploads` lists only the calling connection's uploads, and `GET /v1/file_uploads/{file_upload_id}` reads one.

### Attach and read back

You can attach an upload to:

- a file, image, PDF, audio or video block;
- a page icon or cover;
- a files property.

It reads back as `{"type": "file", "file": {"url", "expiry_time"}}`. The URL serves the bytes you sent until `expiry_time`, which falls one hour after each read on the simulator's clock. After that, the URL returns 403.

**Differs from Notion:** Notion serves a file from its own file storage. The simulator serves it under `/_rystic/files/…`.

Upload, served-file and task-status URLs lead back to the simulator; page, database and data source `url`s do not.

### External URL uploads

An `external_url` upload is a placeholder. Creating one returns it `pending`, and its first `GET /v1/file_uploads/{file_upload_id}` reads it `uploaded`.

**Differs from Notion:** Notion fetches the file at the `external_url`. The simulator never fetches it, and the upload's file serves a short `text/plain` text naming the source URL instead.

### Multi-part uploads

Creating a `multi_part` upload, sending its parts and completing it are unconfirmed: they follow Notion's published spec and have not been checked against Notion yet.

**Differs from Notion:** Notion's free tier caps a file at 5 MiB and refuses to create a `multi_part` upload, with 400 `validation_error`. The simulator creates it, as Notion does for a paid workspace. Check a flow that relies on files over 5 MiB against Notion itself.

---

Source: https://www.rystic.ai/docs/simulators/notion/features/file-uploads · Markdown: https://www.rystic.ai/docs/simulators/notion/features/file-uploads.md
