Authentication
Every endpoint under https://research.onfire.so/api requires a credential. There are two kinds:
| Credential | Header | Who it is for | Lifetime |
|---|---|---|---|
| JWT access token | Authorization: Bearer <token> | Everything | 24 hours |
| API key | X-API-Key: rsk_<prefix>_<secret> | A narrow set of machine routes | Until revoked |
The service resolves both into the same internal identity ({user_id, role}), so ownership checks behave identically whichever you present.
This is the single most common surprise in this API. Most endpoints are JWT-only and will ignore an X-API-Key header entirely — you get 401 {"detail": "Missing Bearer token"}, which reads like a broken key but actually means the route never looked for one.
API keys are accepted on exactly these:
| Route | Purpose |
|---|---|
POST /api/notebooks/{id}/files/ingest-text | Add raw text as a source |
POST /api/notebooks/{id}/files/enrich-search | Web-search and ingest results |
POST /api/notebooks/{id}/sonix | Submit media to Sonix for transcription |
POST /api/notebooks/{id}/sonix/{media_id}/poll | Poll Sonix and ingest the transcript |
/api/workflows and /api/workflows/* | Create, list, run and stream workflows |
Notebook CRUD, file upload, generation, templates, artifacts, folders and notifications are all JWT-only. If you are scripting those, log in and refresh a JWT rather than reaching for a key.
POST /api/notebooks/{id}/files/ — and equally upload-zip, ingest-url and the YouTube route — reject X-API-Key outright. This is the gap people hit first when wiring up Zapier, Make or a cron job: the key works for ingest-text, so they assume it works for uploads too, and get a 401 that says nothing about the real cause.
Two ways forward:
- Your source is a file → log in for a JWT and use it. Walkthrough: Automating ingestion end to end.
- Your source is text → use
ingest-textwith a key. No login, no expiry, no refresh loop.
JWT access tokens
The Research API does not issue its own tokens. It validates tokens minted by the OnFire platform's PostgREST auth layer, so you log in against OnFire and use the resulting token here.
POST /auth/login_user
Exchange an email and password for an access token.
- Base URL:
https://research.onfire.so/auth(a same-origin proxy ontohttps://api2.onfire.so/rpc, which avoids CORS in browsers) - Auth required: none
- Direct equivalent:
POST https://api2.onfire.so/rpc/login_user— identical contract, use it from servers
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
p_email | string | yes | Lowercase and trim it before sending; the platform matches exactly |
p_password | string | yes |
curl -X POST https://research.onfire.so/auth/login_user \
-H "Content-Type: application/json" \
-d '{
"p_email": "[email protected]",
"p_password": "your-password"
}'
Example response
The result is wrapped in an array — this is a PostgREST RPC, and PostgREST returns a row set.
[
{
"success": true,
"message": "Login successful",
"user_data": {
"id": "4655aa7e-98e2-42d5-a1c3-9cc3c589a510",
"uuid": "4655aa7e-98e2-42d5-a1c3-9cc3c589a510",
"username": "you",
"first_name": "Your",
"last_name": "Name",
"avatar": null,
"phone_number": "+10000000000",
"is_email_verified": true,
"last_login": "2026-07-30T13:24:27.486667+00:00",
"date_joined": "2025-12-11T20:20:17.407004+00:00"
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
]
Bad credentials return status 200 with success: false and every token field null. Branch on the success field, not on the status code.
[
{
"success": false,
"message": "Invalid email or password",
"user_data": null,
"access_token": null,
"refresh_token": null
}
]
Token lifetimes
| Token | Claim role | Valid for |
|---|---|---|
access_token | authenticated | 24 hours |
refresh_token | refresh | 90 days |
The access token carries sub, user_id, email, role, iat and exp. The Research API reads user_id (falling back to sub) for identity and requires role to be exactly authenticated — a token with any other role is rejected with 403.
POST /rpc/refresh_token
Trade a refresh token for a fresh access token without re-entering a password.
- Base URL:
https://api2.onfire.so - Auth required: none (the refresh token is the credential)
| Field | Type | Required |
|---|---|---|
p_refresh_token | string | yes |
curl -X POST https://api2.onfire.so/rpc/refresh_token \
-H "Content-Type: application/json" \
-d '{"p_refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}'
[
{
"success": true,
"message": "Token refreshed",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
]
Both tokens are rotated — store the new refresh token as well.
Using the token
curl https://research.onfire.so/api/notebooks/ \
-H "Authorization: Bearer $TOKEN"
Query-parameter fallback. Server-Sent Events endpoints accept ?token=<jwt> instead of the header, because the browser EventSource API cannot set custom headers. Prefer the header everywhere else — query strings land in access logs.
curl "https://research.onfire.so/api/notebooks/$NB/jobs/$JOB/stream?token=$TOKEN"
API keys
API keys are for non-interactive clients. They never expire, they are not tied to a browser session, and they are revocable individually.
A key looks like rsk_<prefix>_<secret> — rsk for "research secret key", then a short non-secret prefix used for display, then 32 bytes of entropy. Only the SHA-256 hash of the full key is stored, so a leaked database row cannot be turned back into a working key.
POST /api/api-keys
Create a key.
- Auth required: JWT only. An API key cannot create another API key.
- Status:
201 Created
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
label | string | no | "" | Free text, max 200 characters. For your own bookkeeping |
scopes | string[] | no | [] | Stored and returned verbatim. Not currently enforced — see the note below |
curl -X POST https://research.onfire.so/api/api-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"label": "nightly report generator"}'
Example response
{
"id": "9b1f0c2e-4d3a-4f6b-8e21-6a7c5d3b1e90",
"prefix": "Xk3mPq",
"label": "nightly report generator",
"scopes": [],
"created_at": "2026-07-30T13:24:27.486667+00:00",
"last_used_at": null,
"revoked_at": null,
"masked_key": "rsk_Xk3mPq_…",
"plaintext": "rsk_Xk3mPq_9tR2wLxA7vNq3sKdF8hJ1mB5cY0zE4uT6iO2pQ8rS1w"
}
plaintext is returned exactly onceThis is the only response that will ever contain the full key. It is not stored and cannot be recovered — if you lose it, revoke the key and create another.
The scopes array is persisted and echoed back, but no endpoint currently checks it. Treat every key as having the full permissions of the user who created it, and scope access by creating separate accounts rather than by relying on this field.
GET /api/api-keys
List your keys. Metadata only — no hash, no plaintext.
- Auth required: JWT only
curl https://research.onfire.so/api/api-keys \
-H "Authorization: Bearer $TOKEN"
[
{
"id": "9b1f0c2e-4d3a-4f6b-8e21-6a7c5d3b1e90",
"prefix": "Xk3mPq",
"label": "nightly report generator",
"scopes": [],
"created_at": "2026-07-30T13:24:27.486667+00:00",
"last_used_at": "2026-07-30T14:02:11.900012+00:00",
"revoked_at": null,
"masked_key": "rsk_Xk3mPq_…"
}
]
last_used_at is bumped on every successful authentication with that key, which makes it the fastest way to spot a key you can safely retire.
DELETE /api/api-keys/{key_id}
Revoke a key. Takes effect immediately — the next request presenting it gets a 401.
- Auth required: JWT only
curl -X DELETE https://research.onfire.so/api/api-keys/9b1f0c2e-4d3a-4f6b-8e21-6a7c5d3b1e90 \
-H "Authorization: Bearer $TOKEN"
{ "revoked": true, "id": "9b1f0c2e-4d3a-4f6b-8e21-6a7c5d3b1e90" }
Errors
| Status | Detail | When |
|---|---|---|
404 | Key not found or already revoked | Unknown id, a key belonging to someone else, or one already revoked. All three collapse to the same response so key ids cannot be probed |
Using a key
Only on the routes listed at the top of this page.
curl -X POST https://research.onfire.so/api/notebooks/$NOTEBOOK_ID/files/ingest-text \
-H "X-API-Key: rsk_Xk3mPq_9tR2wLxA7vNq3sKdF8hJ1mB5cY0zE4uT6iO2pQ8rS1w" \
-H "Content-Type: application/json" \
-d '{"text": "Notes from the vendor call…", "title": "Vendor call"}'
On those routes the API key wins if you send both credentials.
Errors
| Status | Detail | When |
|---|---|---|
401 | Invalid or missing API key | Unknown or revoked key on a route that accepts keys. Response carries WWW-Authenticate: X-API-Key |
401 | Missing Bearer token | You sent a key to a JWT-only route. The key was never inspected — send a JWT instead |
Those two details distinguish "bad key" from "wrong route", which is worth branching on when debugging.
Rate limits on key-authenticated routes
ingest-text and enrich-search enforce a request-rate cap of 120 requests per hour by default, on top of the credit cost. Each API key gets its own bucket; JWT callers are bucketed per user. See Errors & Limits.
Workspaces
Requests are resolved against an active workspace, which scopes things like shared templates.
Set it with the X-Workspace-Id header, or the ?workspace_id= query parameter — the header wins if both are present.
curl https://research.onfire.so/api/templates/all \
-H "Authorization: Bearer $TOKEN" \
-H "X-Workspace-Id: 3f8c1a22-7b91-4d0e-9c55-2a6b8e4f1d33"
Resolution rules:
- If the requested workspace is one you belong to, it becomes active.
- If it is not, the request is not rejected — the header is ignored and your personal workspace is used instead. Membership is never granted by asking.
- If you send nothing, your personal workspace is used, and created lazily if it does not exist yet.
Workspace resolution is best-effort by design: if the workspace tables are briefly unavailable, the request still authenticates and simply proceeds without workspace context rather than failing.
Authentication errors
| Status | Detail | Cause |
|---|---|---|
401 | Missing Bearer token | No Authorization header and no ?token= |
401 | Invalid token: <reason> | Malformed, tampered, or expired JWT. Refresh and retry |
401 | Token missing user identity claim | JWT carries neither user_id nor sub |
401 | Invalid or missing API key | Unknown or revoked X-API-Key |
403 | Role '<role>' is not permitted | JWT role is not authenticated — most often a refresh token used as an access token |
A 401 means the credential is bad; get a new one. A 403 means the credential is valid but the wrong kind, and retrying will not help.