API
The BrainSTEM REST API gives programmatic access to the complete data model: every record you can see in the web interface can be read, created, changed and deleted through HTTPS endpoints that return JSON. API tools exist for Python and MATLAB, AI assistants can connect through the MCP server or the agent skills, and the server describes itself in a machine-readable reference that is generated from its code.
Table of contents
- Quick start
- Authentication
- Endpoint structure
- Available endpoints
- Public vs private portals
- HTTP methods
- Responses
- Query parameters
- Writing records
- Other endpoints
- Error handling
- Rate limits
Quick start
- Create a token on your API token page (see Authentication) and copy it.
Install the Python client and put the token in the environment:
pip install brainstem_python_api_tools export BRAINSTEM_API_TOKEN="<token>"List your sessions and create one:
from brainstem_api_tools import BrainstemClient client = BrainstemClient() # reads BRAINSTEM_API_TOKEN sessions = client.load("session").json()["sessions"] print(len(sessions), "sessions on the first page") created = client.save("session", data={ "name": "Example session", "projects": ["<project-id>"], "session_type": "subject", "subjects": ["<subject-id>"], "date_time": "2026-03-02T10:00:00Z", }).json() print(created["session"]["id"])
The same read and write with curl:
curl -H "Authorization: Bearer $BRAINSTEM_API_TOKEN" \
https://www.brainstem.org/api/private/stem/session/
curl -X POST \
-H "Authorization: Bearer $BRAINSTEM_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Example session", "projects": ["<project-id>"], "session_type": "subject", "subjects": ["<subject-id>"], "date_time": "2026-03-02T10:00:00Z"}' \
https://www.brainstem.org/api/private/stem/session/
Replace <project-id> and <subject-id> with the ids of entries you can change; open the entry in the web interface and copy the id from the address bar or the API button (Finding an ID). A new session is recorded as performed unless you send "status": "planned". The required fields of every resource are on its page under Available endpoints.
Authentication
BrainSTEM uses Personal Access Tokens (PATs) for API authentication. Because BrainSTEM enforces two-factor authentication (2FA), tokens are issued through the browser only: your password is never typed into a script or a command line tool.
| Token type | Lifetime | How to obtain | Best for |
|---|---|---|---|
| Personal Access Token | One year from creation. Using the token does not extend it; create a new one before it expires. | Web UI, or the device authorization flow built into the Python and MATLAB tools | Scripts, integrations, CLI tools, AI assistants |
A user can hold up to 10 active tokens. Each token has a name, records when and from where it was last used, and can be revoked at any time. Revocation takes effect immediately. Checking a token shows when it expires.
Option 1: Create a token in the web UI
Open your API token management page (Profile → Security & access → API tokens), click Create New Personal Access Token, enter a descriptive name such as “Analysis pipeline” and confirm. You are asked to confirm your password and 2FA code first. Copy the token right away; it is shown only once.
Use it as a bearer token:
curl -H "Authorization: Bearer $BRAINSTEM_API_TOKEN" \
https://www.brainstem.org/api/private/stem/project/
A token has no scopes: whoever holds it can read, change, delete and publish with all your rights until it expires a year later or you revoke it. Keep it out of shared code and notebooks, for example in the
BRAINSTEM_API_TOKENenvironment variable that all BrainSTEM tools read, and revoke it on your token page if it leaks.
Option 2: Device authorization flow
The Python and MATLAB tools and the brainstem command line sign you in through the browser. The tool asks the server for a device session, opens the approval page, and polls until you approve. The approval page requires your normal login including 2FA.
In a headless environment (a server or a container without a browser) the tool prints the approval URL and a short code; open the URL on any device, enter the code and approve.
A token obtained this way appears on your token page with the client name and a timestamp, has the same one-year lifetime, and can be revoked like any other. The requests behind the flow are described under Device authorization protocol, for authors of other clients.
Checking a token
GET /api/auth/token/info/ with the bearer header returns the token’s user, type, issue and expiry times. It answers 401 when the token is invalid or revoked.
Authentication in the Python and MATLAB tools
- Python:
BrainstemClient()readsBRAINSTEM_API_TOKEN, then a cached token, and otherwise opens the browser;BrainstemClient(headless=True)prints the code instead. See the Python API tool. - MATLAB:
BrainstemClient()readsBRAINSTEM_API_TOKEN, then a cached token, and otherwise opens the browser. See the MATLAB API tool. - Web API tool: uses your browser session; no token needed. See the Web API tool.
- MCP and agent skills: configure the bearer header once; see MCP server and Agent skills.
Both clients and the NWB exporter also read BRAINSTEM_API_URL to talk to another server than https://www.brainstem.org.
Security best practices
- Never type your password into a script. Use the device flow or create a token in the web UI.
- Keep tokens out of version control, logs and shared notebooks.
- Give each script, machine and AI application its own named token so you can revoke one without touching the others.
- Delete unused tokens on your token management page.
Device authorization protocol
The requests the clients make during the device authorization flow:
# Step 1 — the tool starts a device session (no authentication)
POST /api/auth/device/ body: {"client_name": "my-script"}
→ {"session_id": "...", "device_code": "...", "user_code": "WXYZ-1234",
"verification_uri": "https://www.brainstem.org/account/authorize/",
"verification_uri_complete": "https://www.brainstem.org/account/authorize/?session=...",
"expires_in": 900, "interval": 5}
# Step 2 — you approve in the browser (with 2FA)
# Step 3 — the tool polls until approved
POST /api/auth/device/token/ body: {"device_code": "..."}
→ {"status": "authorization_pending"} ← keep polling every 5 s
→ {"status": "success", "token": "...", "token_id": 42, "token_name": "my-script 2026-03-29 14:30:10"}
All poll responses return HTTP 200 with a status in the body:
{"status": "authorization_pending"} // keep polling
{"status": "success", "token": "..."} // done; the token is returned exactly once
{"error": "access_denied"} // you clicked Deny
{"error": "expired_token"} // the 15-minute window elapsed; start again
{"error": "already_used"} // the token was already collected
Endpoint structure
All record endpoints follow one pattern:
https://www.brainstem.org/api/{portal}/{app}/{resource}/
https://www.brainstem.org/api/{portal}/{app}/{resource}/{id}/
| Part | Value |
|---|---|
portal | private (records you can read, needs a token) or public (records of public projects, read-only, no token) |
app | stem, modules, personal_attributes, resources, taxonomies, dissemination or users |
resource | the lower-case model name, for example session, procedure, specimenset, experimentalinterface or anatomicalregion |
id | the UUID of one record |
Examples:
https://www.brainstem.org/api/private/stem/session/
https://www.brainstem.org/api/private/stem/session/<id>/
https://www.brainstem.org/api/private/modules/procedure/
https://www.brainstem.org/api/public/stem/project/
taxonomies/brainregion is a deprecated alias of taxonomies/anatomicalregion; use the new name.
Available endpoints
Every resource has its own page with its fields, write rules, permissions and examples. The pages are generated from the server code, so they match the current release.
Core records
- STEM: projects, subjects, sessions, specimen sets and specimens, cohorts, collections, breedings and epochs.
- Modules: procedures, procedure episodes and logs, specimen procedures and logs, experimental interfaces, material usages, data acquisition, manipulations, behaviors, equipment, consumable stock, and the templates for each of them.
- Personal attributes: setups, inventories, behavioral assays, data storage, licenses and protocols.
Shared catalog, taxonomies and dissemination
- Resources: consumables, hardware devices and suppliers.
- Taxonomies: species, strains, anatomical regions and atlas references, coordinate transforms, behavioral categories and paradigms, setup types and regulatory authorities.
- Dissemination: publications and journals.
Collaboration
- Users: users, groups and laboratories.
Supporting endpoints
- Templates: the templates that prefill procedures, episodes, interfaces, material rows, data acquisitions and manipulations.
- Approvals: the approval requests behind submissions to the shared catalogs and taxonomies.
- Invitations and requests: membership invitations to projects, groups and personal attributes, join requests and pending invitations.
- Link tables: the many-to-many rows with fields of their own, such as cohort subjects and the interfaces of a data acquisition.
Technical reference
- Schemas: the type-specific JSON payloads (
type+details, coordinate systems) and their versions. - Relationships: how resources connect, as the queries that follow each link.
- Exports: a project or a session as one versioned JSON document.
- Public schemas: the permanent URLs of every schema version.
- Agent reference: the server’s own machine-readable description of its API.
- MCP server: access to the data model and your records for AI applications such as Claude Code and Cursor.
Public vs private portals
Private portal
- Access: a Personal Access Token, or your browser session in the Web API tool.
- Content: records of the projects you are a member of and the lab resources shared with your groups.
- Operations: read, create, change and delete, according to your role on each record.
Public portal
- Access: open, no token.
- Content: records of projects that their owners made public, and lab resources made public individually.
- Operations: read only. An empty public result says nothing about private data.
https://www.brainstem.org/api/public/stem/session/
HTTP methods
- GET: retrieve a list or one record.
- POST: create a record.
- PATCH: change the fields you send (recommended for updates).
- PUT: update a record with the complete record. Fields you leave out are kept, not cleared, so PATCH does the same with less to send.
- DELETE: delete a record.
Responses
A list response is an object with the plural response key of the resource and a meta object:
{
"sessions": [ ... ],
"meta": {"page": 1, "per_page": 100, "total_results": 231, "total_pages": 3}
}
A detail, create or update response uses the singular key: {"session": {...}}. The keys are listed on each resource page and do not always match the resource name (data_acquisition, consumable_stock, anatomical_region).
Relations are returned as ids. Dates are ISO 8601 strings. Type-specific details are JSON objects validated against the schema of the selected type.
Query parameters
With curl, pass -g (--globoff), because curl otherwise reads {} and [] in a URL as its own globbing syntax, and quote the URL so the shell leaves &, [] and {} alone. The Python and MATLAB tools build these parameters for you.
curl -g -H "Authorization: Bearer $BRAINSTEM_API_TOKEN" \
"https://www.brainstem.org/api/private/stem/session/?filter{name.icontains}=rat&sort[]=-date_time&per_page=20"
Filtering
/?filter{<field>}=<value>
/?filter{<field>.<operator>}=<value>
/?filter{<field>.<related field>}=<value> (nestable relations only)
Operators: in, any, all, icontains, contains, startswith, istartswith, endswith, iendswith, iexact, eq, gt, gte, lt, lte, range, isnull, year, month, day, week_day. No operator means exact match. Repeat the parameter for in:
/?filter{type.in}=ExtracellularEphys&filter{type.in}=TwoPhotonMicroscopy
A filter can follow a relation into the related record only when that relation is nestable, that is, when the server lets a query step through it into the related record’s fields (marked “nestable” in the field table of each resource page). Any other relation can only be the last segment and is compared with ids:
| Query | Works? |
|---|---|
procedure/?filter{subject}=<subject-id> | yes, subject is the last segment |
procedure/?filter{target_regions.acronym}=CA1 | yes, target_regions is nestable |
session/?filter{subjects.species}=<species-id> | yes, subjects is nestable |
procedure/?filter{equipment.name}=Rig | no, equipment is not nestable; look the equipment up first and filter with filter{equipment.in}=<ids> |
Tags are filtered by name: filter{tags.name}=control (also .in and .icontains); records return tags as names. Type-specific details cannot be filtered on the server.
Sorting
/?sort[]=name
/?sort[]=-date_time
/?sort[]=-date_time&sort[]=name
Including related records
/?include[]=projects.*
/?include[]=dataacquisition.*&include[]=subjects.*
Included records are returned next to the main list, under their own plural key (“sideloaded”), not nested inside each record. Only nestable relations can be included; on any other relation include[] is ignored and you get ids only. Only records you can read are included, and the relation’s id list in that response then shows only those (see the warning under Writing records). exclude[]=<field> drops a field from the response.
Pagination
Lists are paged with page and per_page:
- Default page size: 100 records
- Maximum page size: 500 (larger values are reduced;
meta.per_pagereports the size used) limitandoffsetare accepted as aliases whenoffsetis a multiple oflimit; other offsets return400exclude_count=1skips the total count for faster responses
/?per_page=50&page=3
/?limit=50&offset=100 # the same page
Follow page up to meta.total_pages, or use load_all in the Python and MATLAB tools.
Planned records
Procedures, specimen procedures, sessions and breedings carry a status of planned, performed or cancelled. Listings return performed records only unless you add include_planned=true or filter on status explicitly (filter{status}=planned). A record created without status is recorded as performed. The public portal never returns planned records.
Combining parameters
/?filter{projects}=<project-id>&sort[]=-date_time&include[]=subjects.*&per_page=200
Writing records
- Create with POST, update with PATCH (send only the fields you change).
- Required fields are listed on each resource page. The server validates with the same rules as the web forms and answers
400with{"field": ["message"]}. - Lists replace: sending
projects,subjects,equipment,target_regionsortagsreplaces the whole list. Read the record first, change the list, send the full list. - JSON payloads replace:
detailsand the coordinate payloads are replaced as a whole. Read, merge, send the full object. - Type and payload go together:
typepicks a schema type and the payload must match it. The resource page names the payload field to send (for exampledetailson procedures,details_jsonon material usages). The server sets every*_schema_versionfield itself; do not send one. - Some records need form-only inputs that are not returned in responses, for example
sourceon a material usage (inventory, catalog or manual). They are listed under “Form-only inputs” on the resource page. - Writes to shared taxonomies and catalog resources by users without approval rights create an approval request instead of the record.
- Deleting a record that other records still reference returns
400(“still referenced by …”).
A list read with
include[]holds only the linked records you can read. Sending that list back replaces the stored one, so the links to records you cannot see are removed. Read the record withoutinclude[]before you change one of its lists.
PUT .../materials/replaces the whole material collection: every row missing fromitemsis deleted, and a request withoutitemsdeletes them all. When you sendstages, stages missing from them are deleted too. Read the collection withGETfirst, change it, and send it back with itsexpected_parent_updated_at; the server answers409if the record changed in between.
Other endpoints
| Endpoint | Purpose |
|---|---|
GET /api/private/recent-activity/ | What changed lately in records you can read. Parameters limit (max 200), actor_filter, action_filter, model_type, search, date_from, date_to, ownership, active_projects_only. |
GET/POST .../stem/project/<id>/export/, .../stem/session/<id>/export/ | Project and session exports; see Exports. |
GET .../taxonomies/anatomicalregion/<id>/descendants/ | The regions below a region in its atlas. |
GET/PUT .../procedure/<id>/materials/ (also specimen procedures and interfaces) | Read or replace the ordered material usage rows of a record. |
POST /api/auth/device/, POST /api/auth/device/token/, GET /api/auth/token/info/ | Device authorization flow and token information. |
GET /reference/, GET /schemas/ | The agent reference and the public schemas; no token needed. |
POST /mcp/ | The MCP server. |
Error handling
| Status | Meaning | What to do |
|---|---|---|
200 / 201 / 204 | success / created / deleted | |
400 {"field": [...]} | validation failed | fix the named fields |
400 ["Invalid nested filter field: x"] | a filter followed a relation that is not nestable | filter by ids instead |
401 | token missing, invalid, expired or revoked | sign in again or create a new token |
403 | no permission for this record or action | ask a project manager for the role you need |
404 | no such record, or not visible to you | the record may exist in a project you cannot read |
409 | stale write (material lists) or export job not ready | re-read and retry, or wait |
429 | rate limited | wait and retry; do not loop |
Rate limits
| Scope | Limit |
|---|---|
| Anonymous requests (public portal) | 60 per minute per IP |
Agent reference (/reference/) and public schemas (/schemas/) | 600 per minute per IP, each |
| Authenticated requests | 300 per minute per user |
| Device flow | 10 session starts and 30 polls per minute |
| Exports (direct and background jobs together) | 30 per hour per user |
| MCP messages | 300 per minute |
Requests over the limit receive 429 with a Retry-After header.