Notebooks & Sources
A notebook is a container for source material. A source is one ingested document — a PDF, a scraped web page, a transcript, a block of pasted text. Everything you generate is grounded in the sources of one notebook.
Ingestion is asynchronous. A source is created with status: "ingesting" and becomes "ready" once it has been chunked and embedded in the background. Generating against a notebook whose sources are still ingesting will retrieve nothing useful, so wait for ready.
Permissions
Access is workspace-based. Every notebook belongs to a workspace, and your role there determines what you may do.
| Role | Rank | Can |
|---|---|---|
viewer | 1 | Read notebooks, files, templates and artifacts |
member | 2 | Everything above, plus add sources and generate |
admin | 3 | Everything above, plus manage notebook settings and membership |
owner | 4 | Everything above, plus delete the notebook |
Roles are cumulative — a higher rank includes every capability of the ranks below it.
- 404 — the notebook does not exist, or you are not a member of its workspace at all. These are deliberately indistinguishable so nobody can probe for the existence of other tenants' notebooks.
- 403 — you are a member of the workspace but your role is too low for this operation. Safe to disclose, since you already know the notebook exists.
So a 404 on a notebook you believe exists usually means a missing workspace membership, not a wrong id.
Notebooks
POST /api/notebooks/
Create a notebook.
- Auth required: JWT
- Status:
201 Created
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | — | Display name |
brain_prompt | string | no | "" | Standing instructions prepended to the system prompt of every generation in this notebook |
curl -X POST https://research.onfire.so/api/notebooks/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Q2 competitive research", "brain_prompt": "Always cite the source file for each claim."}'
Example response
{
"id": "a4f1fcc9-b9a3-4beb-a76e-70d02dacae51",
"name": "Q2 competitive research",
"created_at": "2026-05-08T08:02:19.762701Z",
"file_count": 0,
"brain_prompt": "Always cite the source file for each claim.",
"branding": {},
"chatbot_enabled": false,
"chatbot_name": "Research Assistant",
"chatbot_prompt": "",
"chatbot_welcome": "Hi! How can I help you today?",
"chatbot_color": "#FF6B1F",
"chatbot_token": "de0d53a8-7d6d-4d98-a068-6478e32e29e2"
}
brain_prompt is worth understanding: it is prepended to the system prompt of every generation in this notebook, alongside the brand kit. It is not a placeholder and does not need to be referenced in a template. Use it for standing instructions — house style, citation rules, terminology — rather than repeating them in each extra_context.
GET /api/notebooks/
List your notebooks. Returns the same shape as above, including file_count.
- Auth required: JWT. Viewer role.
curl https://research.onfire.so/api/notebooks/ \
-H "Authorization: Bearer $TOKEN"
GET /api/notebooks/{notebook_id}
Fetch one notebook.
- Auth required: JWT. Viewer role.
PATCH /api/notebooks/{notebook_id}
Update a notebook. Every field is optional; omitted fields are left unchanged.
- Auth required: JWT. Admin role.
| Field | Type | Notes |
|---|---|---|
name | string | null | |
brain_prompt | string | null | |
branding | object | null | Brand kit. null leaves it unchanged; {} explicitly clears it |
curl -X PATCH https://research.onfire.so/api/notebooks/$NOTEBOOK_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Q2 competitive research (final)"}'
Note the branding convention: omitting the key and sending null both mean "no change", while sending {} means "reset to default". This is the one field where an empty object is not the same as absent.
PATCH /api/notebooks/{notebook_id}/chatbot
Configure the notebook's embeddable chatbot.
- Auth required: JWT. Admin role.
| Field | Type | Notes |
|---|---|---|
chatbot_enabled | bool | null | |
chatbot_name | string | null | Display name |
chatbot_prompt | string | null | System prompt |
chatbot_welcome | string | null | Opening message |
chatbot_color | string | null | Hex accent colour |
DELETE /api/notebooks/{notebook_id}
Delete a notebook and its sources.
- Auth required: JWT. Owner role only — an admin cannot delete a notebook.
- Status:
204 No Content, empty body
Adding sources
Six routes ingest material. Five sit under /api/notebooks/{notebook_id}/files; the YouTube route sits directly under the notebook. They differ only in where the content comes from; each ends the same way — an R2 object, a file row, and a background embedding job.
All require member role or above.
Which credential each route accepts
This is the thing to check before you write a script. Most ingestion routes are JWT-only — they do not look at X-API-Key at all, so presenting a key gets you 401 {"detail": "Missing Bearer token"}, which reads like a bad key but means the route never inspected it.
| Route | JWT | API key |
|---|---|---|
POST /files/ — upload a file | ✅ | ❌ |
POST /files/upload-zip | ✅ | ❌ |
POST /files/ingest-url | ✅ | ❌ |
POST /{notebook_id}/youtube | ✅ | ❌ |
POST /files/ingest-text | ✅ | ✅ |
POST /files/enrich-search | ✅ | ✅ |
POST /api/notebooks/{id}/files/ is JWT-only. There is no API-key path for binary upload, zip upload, URL ingestion or YouTube ingestion — all four return 401 Missing Bearer token when given a key, before any of your payload is read.
If you are automating from Zapier, Make, a cron job or a CI step and your source is a file, you must log in and use a JWT. See the automation recipe below.
If your source is text you already have in hand, ingest-text accepts a key and is the better fit — no login, no token refresh.
POST /api/notebooks/{notebook_id}/files/
Upload a file. This is multipart/form-data, not JSON.
- Auth required: JWT only. An
X-API-Keyis not accepted here - Status:
201 Created
The route is registered as /files/. POST /files without the slash returns a 307 to /files/, and a 307 preserves the method and the body. Your client will re-send the entire upload a second time.
On a 60 MB file that means 120 MB on the wire and double the time; it is also the most common cause of an upload that appears to hang or time out. Write the slash.
# ✅ uploads once
# ❌ uploads twice
| Form field | Type | Required | Notes |
|---|---|---|---|
file | file | yes | The upload itself |
folder_id | string | no | Folder UUID to file it under |
auto_generate | bool | no | Kick off a generation as soon as ingestion completes |
template_id | string | no | Which template to auto-generate. Only meaningful with auto_generate |
topic | string | no | Topic for the auto-generation |
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/ \
-H "Authorization: Bearer $TOKEN" \
Example response
{
"id": "7c1e3a45-2b8d-4f60-9a13-5e7c8d0b2f91",
"filename": "quarterly-report.pdf",
"file_type": ".pdf",
"status": "ingesting",
"chunk_count": null,
"folder_id": null,
"file_size_bytes": 0,
"has_pii": false,
"origin": "upload",
"created_at": "2026-07-30T13:24:27.486667Z"
}
status starts at ingesting and moves to ready (or error). chunk_count is null until ingestion finishes, and file_size_bytes is 0 in this immediate response — both are filled in once the background job completes. Re-read the file from the list endpoint to see the real values.
201 means accepted, not ingestedThe upload has landed in storage and a background job has been queued. The source is not usable for generation until it reaches status: "ready" with a non-zero chunk_count. A file can upload cleanly and still fail to ingest. Always poll — see Knowing when ingestion finished.
Accepted file types
| Category | Extensions | How it is indexed |
|---|---|---|
| Documents | .pdf .docx .pptx .xlsx .csv .txt .md .html .epub | Text extracted directly |
| Audio | .mp3 .wav .m4a .ogg .flac .webm | Transcribed; the transcript is indexed |
| Video | .mp4 .mov .mkv .m4v | Audio track transcribed; the transcript is indexed |
| Images | .png .jpg .jpeg .gif .webp | OCR'd, plus colour and font extraction |
.webm is treated as audioIt appears in both the audio and video families. The audio branch is checked first, so a .webm upload is tagged as an audio source regardless of whether it contains video.
Cloudflare fronts this hostname and rejects request bodies over ~100 MB before they
reach the API. You get an HTML 413 Payload Too Large page — not a JSON API error —
after only a couple of MB have been sent, so a client that parses the response as JSON
will fail confusingly. Verified live: 60 MB uploads fine; 105 MB is rejected at the edge.
Size limit: the API's own cap is 300 MB (413 with File exceeds 300 MB limit) and
nginx allows 1024 MB, but neither is reachable through the public hostname — the ~100 MB
Cloudflare limit binds first. Treat ~100 MB as the real ceiling. This matters most for
video: .mp4, .mov, .mkv and .m4v are accepted extensions, and realistic video files
exceed it.
Errors
| Status | Detail | When |
|---|---|---|
403 | — | Viewer role; ingestion needs member |
404 | Notebook not found | Unknown notebook, or you are not in its workspace |
413 | File exceeds 300 MB limit | The API's own cap. Rarely seen in practice — Cloudflare returns its own HTML 413 at ~100 MB first |
422 | Unsupported file type '<ext>' | Extension not in the accepted list |
POST /api/notebooks/{notebook_id}/files/upload-zip
Upload a zip archive and ingest each member file individually.
- Auth required: JWT only. An
X-API-Keyis not accepted here - Status:
201 Created - Returns: an array of file records, one per extracted file
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/upload-zip \
-H "Authorization: Bearer $TOKEN" \
Members whose extensions are not accepted are skipped rather than failing the whole request. So are entries larger than the 300 MB per-file cap, and anything under a dot-directory or __MACOSX. Nothing tells you what was skipped — compare the length of the response array against the archive's contents.
Size limit: the archive is capped at 200 MB by the API and each extracted member
against the 300 MB per-file cap — but as with a single upload, Cloudflare rejects the
request over ~100 MB before either applies, so ~100 MB is the real ceiling for the
.zip you send. The per-member cap still matters for what is inside the archive,
since members are extracted server-side after the upload has already succeeded.
Errors
| Status | Detail | When |
|---|---|---|
413 | ZIP exceeds 200 MB limit | The archive is over the cap |
422 | Expected a .zip file | The filename does not end in .zip |
422 | Invalid or corrupt ZIP file | Not a readable archive |
422 | ZIP contains no supported files (…) | Every member was skipped — you get a failure, not an empty array |
POST /api/notebooks/{notebook_id}/files/ingest-url
Scrape a public web page and store the extracted text as a source.
- Auth required: JWT only. An
X-API-Keyis not accepted here - Status:
201 Created
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Must start with http:// or https:// |
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/ingest-url \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/2026-market-report"}'
The stored file is a .txt named after the page title, with Source: <url> as its first line for provenance. It returns the same FileOut shape as an upload.
Errors
| Status | Detail | When |
|---|---|---|
400 | Valid URL required (http or https) | Empty or non-HTTP scheme |
422 | Failed to fetch URL: <reason> | The page could not be retrieved |
422 | No text content found at URL | Fetched, but nothing extractable — common on JavaScript-only pages |
POST /api/notebooks/{notebook_id}/files/ingest-text
Ingest raw text directly. Useful for notes, pasted content, or output from your own pipeline.
- Auth required: JWT or API key — the only key-accepting route that ingests content you supply directly
- Status:
201 Created - Rate limited: 120 requests/hour by default
- Cost: 1 credit per call
| Field | Type | Required | Notes |
|---|---|---|---|
text | string | yes | Raw UTF-8. Max 10 MB |
title | string | null | no | Used to build the filename |
folder_id | string | null | no | Folder UUID |
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/ingest-text \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Notes from the vendor call on 29 July…",
"title": "Vendor call notes"
}'
Returns the same FileOut shape as an upload.
Errors
| Status | When |
|---|---|
400 | text is empty or whitespace only |
404 | Notebook not found or not accessible — checked before payload validation, so a non-member never learns whether their payload was valid |
413 | text exceeds 10 MB |
429 | Rate limit exceeded. Honour Retry-After |
POST /api/notebooks/{notebook_id}/youtube
Ingest a YouTube video's transcript.
- Auth required: JWT only. An
X-API-Keyis not accepted here. Member role - Status:
201 Created
| Field | Type | Required |
|---|---|---|
url | string | yes |
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/youtube \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
Note there is no trailing slash on this route, unlike the files/ routes.
Errors
| Status | Detail | When |
|---|---|---|
422 | Varies | The URL is not a usable video, or the transcript is empty — most often captions are disabled |
502 | Failed to fetch transcript: <reason> | The transcript service itself failed. Safe to retry |
POST /api/notebooks/{notebook_id}/files/enrich-search
Run a web search and ingest each result as its own source.
- Auth required: JWT or API key
- Status:
201 Created - Rate limited: 120 requests/hour by default
- Cost: 1 credit per ingested result, not per call
| Field | Type | Required | Notes |
|---|---|---|---|
query | string | yes | The search query |
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/enrich-search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "enterprise SaaS pricing benchmarks 2026"}'
Returns the list of created file ids. Because it charges per result, one call can consume several credits — budget accordingly.
A 404 is returned if the query yields nothing, as well as if the notebook is inaccessible. Ownership is checked first, so the two cannot be distinguished by a non-member.
Knowing when ingestion finished
Every ingest route returns 201 the moment the content is stored and a background embedding job is queued. That is not the same as the source being usable. Between the two there is a job that can fail — an unreadable PDF, a transcription that returned nothing, an embedding call that errored — and when it does, the row simply stops at status: "error" or sits at ready with chunk_count: 0. Nothing is pushed to you.
So treat 201 as "accepted" and poll for the real outcome. A source is genuinely in the notebook when both hold:
status == "ready"chunk_count > 0
Checking status alone will let a silently-empty source through.
Two endpoints report this. Both need only viewer role, and both are JWT-only.
| Endpoint | Use when |
|---|---|
GET /api/notebooks/{id}/files/ | You want the full file records — filename, size, folder, PII flag |
GET /api/notebooks/{id}/files/index-status | You only want progress. Leaner: file_id, filename, status, chunk_count, indexed_at, has_hash |
curl -s "https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/index-status" \
-H "Authorization: Bearer $TOKEN"
[
{
"file_id": "7c1e3a45-2b8d-4f60-9a13-5e7c8d0b2f91",
"filename": "quarterly-report.pdf",
"status": "ready",
"has_hash": true,
"indexed_at": "2026-07-30T13:24:29.466078+00:00",
"chunk_count": 14
}
]
How long to wait. A few KB of text is ready in about a second. A large PDF takes proportionally longer. Audio and video go through transcription first and can take minutes — roughly the duration of the media. Poll every 2–3 seconds with a ceiling, and treat "still ingesting after the ceiling" as a failure to surface, not as success.
Automating ingestion end to end
The complete flow for a script, a cron job, or a Zapier/Make step. This is the file-upload path, so it is JWT-only — begin by logging in.
1. Get a token. Access tokens last 24 hours; a long-lived automation should either log in per run or store the refresh token and call POST https://api2.onfire.so/rpc/refresh_token. See Authentication.
TOKEN=$(curl -s -X POST https://research.onfire.so/auth/login_user \
-H "Content-Type: application/json" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["access_token"])')
Remember that a failed login is still HTTP 200 with success: false — check that field before using the token.
2. Create or reuse a notebook. Reuse an existing id if you have one; otherwise create it once and store the id. Note the field is name, not title.
NOTEBOOK_ID=$(curl -s -X POST https://research.onfire.so/api/notebooks/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Inbox automation"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
3. Upload the file. Mind the trailing slash — without it the whole body is re-sent.
FILE_ID=$(curl -s -X POST "https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@./quarterly-report.pdf" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
4. Poll until it is genuinely ready.
for i in $(seq 1 60); do
STATE=$(curl -s "https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/index-status" \
-H "Authorization: Bearer $TOKEN" \
| python3 -c "
import json,sys
row = next((r for r in json.load(sys.stdin) if r['file_id'] == '$FILE_ID'), None)
print('missing' if row is None else f\"{row['status']}:{row['chunk_count'] or 0}\")
")
case "$STATE" in
ready:0) echo 'ingested but empty — no chunks'; exit 1 ;;
ready:*) echo "ready ($STATE)"; break ;;
error:*) echo 'ingestion failed'; exit 1 ;;
esac
sleep 3
done
Timeouts and size. Two different limits apply, and the smaller one is not the obvious one. Size is capped at ~100 MB by Cloudflare at the edge (see the callout above) — the API's 300 MB constant and nginx's 1024 MB body limit are both unreachable through this hostname. Duration is bounded by the edge proxy's 600-second read timeout, which is generous: a 96 MB upload has to sustain only ~1.3 Mbps to finish inside it. So you will hit the size ceiling long before the time one. Still set your own client timeout generously rather than relying on a default of 30 seconds.
ingest-text takes an X-API-Key, so there is no login step, no token expiry and no refresh handling. Create a key once with POST /api/api-keys and store it in your automation's secret manager. It costs 1 credit per call and is capped at 120 calls/hour.
Managing sources
GET /api/notebooks/{notebook_id}/files/
List a notebook's sources. Viewer role.
Poll this after ingesting to watch status move from ingesting to ready.
curl https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/ \
-H "Authorization: Bearer $TOKEN"
GET /api/notebooks/{notebook_id}/files/{file_id}/download
Get a signed download URL for the original file.
{
"download_url": "https://8c424e771e04328e2ef63fcc61f6cf8e.r2.cloudflarestorage.com/onfire1/…?X-Amz-Signature=…",
"filename": "quarterly-report.pdf"
}
GET /api/notebooks/{notebook_id}/files/index-status
Report indexing progress across the notebook. Use it to decide when generation will produce good results.
Other source operations
| Method | Path | Does |
|---|---|---|
PATCH | /files/{file_id}/rename | Rename. Body: {"filename": "…"} |
PATCH | /files/{file_id}/move | Move between folders |
PATCH | /files/{file_id}/notebook | Move to another notebook. Body: {"target_notebook_id": "…"} |
POST | /files/{file_id}/copy | Copy into another notebook. Body: {"target_notebook_id": "…"} |
POST | /files/{file_id}/reindex | Re-embed one source. Returns 202 |
POST | /files/reindex-all | Re-embed every source. Returns 202 |
DELETE | /files/{file_id} | Delete a source. Returns 204 |
Reindexing is worth knowing about: if a source was ingested while the pipeline was misbehaving, or you have changed chunking configuration, reindex rebuilds its embeddings without re-uploading the file.
Folders
Folders organise sources within a notebook. They are optional and one level of nesting is supported via parent_folder_id.
GET /api/notebooks/{notebook_id}/folders/
List folders. Viewer role.
[
{
"id": "b2d4f6a8-1c3e-5079-8b2d-4f6a81c3e507",
"name": "Competitor filings",
"color": "#FF6B1F",
"parent_folder_id": null
}
]
POST /api/notebooks/{notebook_id}/folders/
Create a folder. Member role. Returns 201.
| Field | Type | Notes |
|---|---|---|
name | string | Required |
color | string | null | Hex |
parent_folder_id | string | null | For nesting |
DELETE /api/notebooks/{notebook_id}/folders/{folder_id}
Delete a folder. Member role. Returns 204.
Deleting a folder does not delete the sources inside it — they become unfiled.
Next
With sources ingested and ready, move on to Generation.