Skip to main content

Authentication

Every endpoint under https://research.onfire.so/api requires a credential. There are two kinds:

CredentialHeaderWho it is forLifetime
JWT access tokenAuthorization: Bearer <token>Everything24 hours
API keyX-API-Key: rsk_<prefix>_<secret>A narrow set of machine routesUntil revoked

The service resolves both into the same internal identity ({user_id, role}), so ownership checks behave identically whichever you present.

API keys work on only a handful of routes

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:

RoutePurpose
POST /api/notebooks/{id}/files/ingest-textAdd raw text as a source
POST /api/notebooks/{id}/files/enrich-searchWeb-search and ingest results
POST /api/notebooks/{id}/sonixSubmit media to Sonix for transcription
POST /api/notebooks/{id}/sonix/{media_id}/pollPoll 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.

There is no API-key path for uploading a file

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-text with 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 onto https://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

FieldTypeRequiredNotes
p_emailstringyesLowercase and trim it before sending; the platform matches exactly
p_passwordstringyes
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",
"email": "[email protected]",
"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..."
}
]
A failed login is still HTTP 200

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

TokenClaim roleValid for
access_tokenauthenticated24 hours
refresh_tokenrefresh90 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)
FieldTypeRequired
p_refresh_tokenstringyes
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
FieldTypeRequiredDefaultNotes
labelstringno""Free text, max 200 characters. For your own bookkeeping
scopesstring[]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 once

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

Scopes are recorded, not enforced

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

StatusDetailWhen
404Key not found or already revokedUnknown 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

StatusDetailWhen
401Invalid or missing API keyUnknown or revoked key on a route that accepts keys. Response carries WWW-Authenticate: X-API-Key
401Missing Bearer tokenYou 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

StatusDetailCause
401Missing Bearer tokenNo Authorization header and no ?token=
401Invalid token: <reason>Malformed, tampered, or expired JWT. Refresh and retry
401Token missing user identity claimJWT carries neither user_id nor sub
401Invalid or missing API keyUnknown or revoked X-API-Key
403Role '<role>' is not permittedJWT 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.