Skip to main content

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.

RoleRankCan
viewer1Read notebooks, files, templates and artifacts
member2Everything above, plus add sources and generate
admin3Everything above, plus manage notebook settings and membership
owner4Everything above, plus delete the notebook

Roles are cumulative — a higher rank includes every capability of the ranks below it.

404 and 403 mean different things here
  • 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
FieldTypeRequiredDefaultNotes
namestringyesDisplay name
brain_promptstringno""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.
FieldTypeNotes
namestring | null
brain_promptstring | null
brandingobject | nullBrand 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.
FieldTypeNotes
chatbot_enabledbool | null
chatbot_namestring | nullDisplay name
chatbot_promptstring | nullSystem prompt
chatbot_welcomestring | nullOpening message
chatbot_colorstring | nullHex 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.

RouteJWTAPI 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
You cannot upload a file with an API key

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-Key is not accepted here
  • Status: 201 Created
Keep the trailing slash — on this route it costs you the whole body

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
curl -X POST ".../files/" -F "[email protected]"
# ❌ uploads twice
curl -X POST ".../files" -F "[email protected]"
Form fieldTypeRequiredNotes
filefileyesThe upload itself
folder_idstringnoFolder UUID to file it under
auto_generateboolnoKick off a generation as soon as ingestion completes
template_idstringnoWhich template to auto-generate. Only meaningful with auto_generate
topicstringnoTopic 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 ingested

The 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

CategoryExtensionsHow it is indexed
Documents.pdf .docx .pptx .xlsx .csv .txt .md .html .epubText extracted directly
Audio.mp3 .wav .m4a .ogg .flac .webmTranscribed; the transcript is indexed
Video.mp4 .mov .mkv .m4vAudio track transcribed; the transcript is indexed
Images.png .jpg .jpeg .gif .webpOCR'd, plus colour and font extraction
.webm is treated as audio

It 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.

The practical limit is ~100 MB, not 300 MB

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

StatusDetailWhen
403Viewer role; ingestion needs member
404Notebook not foundUnknown notebook, or you are not in its workspace
413File exceeds 300 MB limitThe API's own cap. Rarely seen in practice — Cloudflare returns its own HTML 413 at ~100 MB first
422Unsupported 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-Key is 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

StatusDetailWhen
413ZIP exceeds 200 MB limitThe archive is over the cap
422Expected a .zip fileThe filename does not end in .zip
422Invalid or corrupt ZIP fileNot a readable archive
422ZIP 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-Key is not accepted here
  • Status: 201 Created
FieldTypeRequiredNotes
urlstringyesMust 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

StatusDetailWhen
400Valid URL required (http or https)Empty or non-HTTP scheme
422Failed to fetch URL: <reason>The page could not be retrieved
422No text content found at URLFetched, 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
FieldTypeRequiredNotes
textstringyesRaw UTF-8. Max 10 MB
titlestring | nullnoUsed to build the filename
folder_idstring | nullnoFolder 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

StatusWhen
400text is empty or whitespace only
404Notebook not found or not accessible — checked before payload validation, so a non-member never learns whether their payload was valid
413text exceeds 10 MB
429Rate limit exceeded. Honour Retry-After

POST /api/notebooks/{notebook_id}/youtube

Ingest a YouTube video's transcript.

  • Auth required: JWT only. An X-API-Key is not accepted here. Member role
  • Status: 201 Created
FieldTypeRequired
urlstringyes
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

StatusDetailWhen
422VariesThe URL is not a usable video, or the transcript is empty — most often captions are disabled
502Failed 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
FieldTypeRequiredNotes
querystringyesThe 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.

EndpointUse when
GET /api/notebooks/{id}/files/You want the full file records — filename, size, folder, PII flag
GET /api/notebooks/{id}/files/index-statusYou 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" \
-d '{"p_email": "[email protected]", "p_password": "your-password"}' \
| 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.

If you only need to push text, skip all of this

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

MethodPathDoes
PATCH/files/{file_id}/renameRename. Body: {"filename": "…"}
PATCH/files/{file_id}/moveMove between folders
PATCH/files/{file_id}/notebookMove to another notebook. Body: {"target_notebook_id": "…"}
POST/files/{file_id}/copyCopy into another notebook. Body: {"target_notebook_id": "…"}
POST/files/{file_id}/reindexRe-embed one source. Returns 202
POST/files/reindex-allRe-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.

FieldTypeNotes
namestringRequired
colorstring | nullHex
parent_folder_idstring | nullFor 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.