# Neuroencoder
> Neuroencoder is a hosted embeddings API and Python package for EEG: send a recording, get one 768-dimensional vector per 30-second window from EPI-300K, an EEG foundation model pre-trained on 250,000 hours of clinical EEG.
> ## Agent Instructions
> Neuroencoder is a hosted EEG embeddings API (https://api.neuroencoder.com/v1). The model runs only on its servers. Python package `neuroencoder` on PyPI: client, command line tool and MCP server; it does not contain the model. A trial key comes from `neuroencoder login --email
` or the HTTP device flow (POST /v1/signup with "device": true, then POST /v1/signup/token); a person approves the emailed link once. Main endpoint: POST /v1/embeddings with an EDF, BDF, FIF or NPY file or a JSON array; any montage, any sampling rate; one 768-dimensional vector per window. Errors are JSON with a `fix` field. Reference: https://docs.neuroencoder.com/api. Guide for AI agents: https://docs.neuroencoder.com/agents. OpenAPI: https://api.neuroencoder.com/v1/openapi.json. Research use only; not a medical device.
# Access
Source: https://docs.neuroencoder.com/access
How to get an API key for the Neuroencoder EEG embeddings API, or a collaboration key.
Trial keys for the hosted API are self-serve; larger keys for collaborations are issued by hand. The model runs only on our servers.
**1. Get a trial key**
```bash theme={null}
pip install neuroencoder
neuroencoder login --email you@lab.org
```
The command prints a code and emails a link (valid 30 minutes); approve on the page if it shows the same code, and the key is saved to `~/.config/neuroencoder/api_key`. Without the command line, `POST /v1/signup` with `{"email": ...}` emails a link that shows the key once (see [Signup](/api#signup)). A trial key is valid 30 days with 100 h of EEG per month; signing up again replaces it with the same limits. If the email does not arrive, email [contact@neuroencoder.com](mailto:contact@neuroencoder.com).
**2. For a collaboration key, tell us what you are working on**
Email [contact@neuroencoder.com](mailto:contact@neuroencoder.com) with your institution and intended use, or use the [contact form](https://neuroencoder.com/contact).
**3. Use your key**
```bash theme={null}
export NEUROENCODER_API_KEY=ne_live_...
```
For commercial use, hosted API access beyond the trial or data partnerships, email [contact@neuroencoder.com](mailto:contact@neuroencoder.com).
# For AI agents
Source: https://docs.neuroencoder.com/agents
Use the Neuroencoder EEG embeddings API from an AI agent: llms.txt, skill file, OpenAPI, CLI, MCP server, error contract
Everything an agent needs is available as plain text, a key can be obtained from a terminal, and a local MCP server exposes the API as tools.
## Machine-readable entry points
| What | Where |
| - | - |
| Index of these docs | [`/llms.txt`](https://docs.neuroencoder.com/llms.txt) |
| All docs in one file | [`/llms-full.txt`](https://docs.neuroencoder.com/llms-full.txt) |
| Agent skill (usage, limits, retries in one page) | [`/skill.md`](https://docs.neuroencoder.com/skill.md) |
| Any page as Markdown | append `.md`, e.g. [`/api.md`](https://docs.neuroencoder.com/api.md) |
| API summary for agents | [`https://api.neuroencoder.com/llms.txt`](https://api.neuroencoder.com/llms.txt) |
| OpenAPI | [`https://api.neuroencoder.com/v1/openapi.json`](https://api.neuroencoder.com/v1/openapi.json), interactive at [`/v1/docs`](https://api.neuroencoder.com/v1/docs) |
## Get a key from a terminal
```bash theme={null}
pip install neuroencoder
neuroencoder login --email you@lab.org
```
The command prints a short code and emails a link to that address. A human opens the link and approves only if the page shows the same code; the command then receives the key and saves it to `~/.config/neuroencoder/api_key` (mode 600, `$XDG_CONFIG_HOME` respected). The mail does not contain the code; the human compares the code on the page with the one in the terminal. In Python: `neuroencoder.login("you@lab.org")`.
The same device flow over HTTP:
```bash theme={null}
curl -X POST https://api.neuroencoder.com/v1/signup -H "content-type: application/json" -d '{"email": "you@lab.org", "device": true}'
# -> device_code (keep it secret), user_code (show it to the human), interval 5, expires_in 1800
curl -X POST https://api.neuroencoder.com/v1/signup/token -H "content-type: application/json" -d '{"device_code": ""}'
# every 5 s: {"status": "pending"} until the link is approved, then the key exactly once
```
An agent that can read the mailbox may approve a signup it started itself by posting the token from the link, `POST /v1/verify` with `{"t": ""}`: for a device signup this approves it and the key still goes to `/v1/signup/token`; for a signup without `"device": true` the response is the key itself.
A trial key is valid 30 days with 100 h of EEG per month, 30 requests per minute, 2,000 windows per minute, 2 requests in flight and 1 job at a time. Signing up again while the address has a live trial key replaces it: the new key keeps the same limits, expiry and this month's usage, and the old key is revoked when the new one is issued. The key is looked up as the `api_key` argument, then `NEUROENCODER_API_KEY`, then the saved file; keep it out of logs and URLs (send it only in the `Authorization: Bearer` header). A key you already have can be saved with `echo "$KEY" | neuroencoder login --key -`. If the email does not arrive, email [contact@neuroencoder.com](mailto:contact@neuroencoder.com).
## Command line
```bash theme={null}
neuroencoder whoami # tier, limits, usage this month, expiry
neuroencoder inspect night.edf # free, no upload: channels -> regions, duration, windows and seconds embed would bill
neuroencoder embed night.edf -o night.npz # bills; [--stride 30] [--no-filter] [--force]
neuroencoder --json embed night.edf -o night.npz
neuroencoder logout # delete the saved key file
```
`--json` prints exactly one JSON object on stdout; a failure prints `{"error": {"code", "message", "fix"}}` and exits 1 (2 for usage errors). `embed` writes `embeddings` (float32 `[n, 768]`), `t_start` (`[n]`, seconds) and `meta` (a JSON string with model, source, sfreq, stride, channels used and usage) to one `.npz`, and does not overwrite without `--force`. `.npy` input needs `--sfreq` and, unless it has the 8 regional rows, `--channels Fp1,C3,...`.
## MCP server
A local MCP server on stdio. It reads recordings on the machine it runs on, so files are never passed through the model.
```bash theme={null}
pip install 'neuroencoder[mcp]'
```
```json theme={null}
{"mcpServers": {"neuroencoder": {"command": "neuroencoder", "args": ["mcp"]}}}
```
| Tool | Cost | What it does |
| - | - | - |
| `inspect_recording(path, stride_seconds=30, sfreq, channel_names)` | free, local | Header only: rate, duration, channel-to-region mapping, unmatched channels, and the windows and seconds `embed_recording` would bill. `problem` is set when embedding would fail. |
| `embed_recording(path, stride_seconds=30, output, sfreq, channel_names)` | bills 30 s of EEG per window | Uploads the recording and writes `.npz` next to it (never overwrites). Returns the path, window count, time range, regions and billed seconds. |
| `find_similar(embeddings, window or time_seconds, k=5, corpus)` | free, local | Cosine top-k for one window of an `.npz`, within the same recording (skipping windows that overlap the query) or across the `.npz` files in `corpus`. |
| `plot_embeddings(embeddings, output, color="time")` | free, local | Writes a PNG of a 2-D PCA of an `.npz`. |
| `account()` | free | Tier, rate limit, monthly quota, usage and expiry of the key in use (key shown as prefix...last4). |
Vectors are written to `.npz` files and never returned to the model; tools return paths and summaries. The server reads only `.edf`, `.bdf`, `.fif`, `.npy` and its own `.npz` files, writes only `.npz` and `.png`, and uploads only the recording given to `embed_recording`. Use absolute paths. Tool errors arrive as `code: message Fix: ...`.
## Error contract
Every API error is JSON with the same shape; `param` names the offending field and `fix` says what to change:
```json theme={null}
{"error": {"message": "Invalid or missing API key.", "type": "authentication_error", "code": "invalid_api_key",
"param": null, "fix": "Send 'Authorization: Bearer ne_live_...' (56 characters). Get a key with `pip install neuroencoder && neuroencoder login --email you@lab.org`, or POST https://api.neuroencoder.com/v1/signup {\"email\": ..., \"device\": true}; docs https://docs.neuroencoder.com/agents"}}
```
Branch on `code`, apply `fix`, and quote the `x-request-id` response header when reporting a problem. The full list of codes is in the [API reference](/api#errors). The Python client raises the same fields as `.status`, `.code`, `.param` and `.fix`.
## Retries and idempotency
Send `Idempotency-Key: ` with every `POST /v1/embeddings` and reuse it when retrying the same request: a repeated request is never billed twice, and the same key with a different request is a 409 `idempotency_key_reused`. Retry 503 `overloaded` and 429 `rate_limit_exceeded`, `concurrency_limit_exceeded` and `windows_rate_limit_exceeded` after `Retry-After`. A 429 without a JSON `code` comes from the gateway or firewall: back off exponentially with jitter. Do not retry `quota_exceeded` or `too_many_queued_jobs`. The Python client, the CLI and the MCP server (2.2.0) retry 503 and `rate_limit_exceeded` themselves; other 429s raise an error. Details: [Retries and idempotency](/api#retries-and-idempotency).
## Limits
1,024 windows per request (8.5 h at the default 30 s stride), `sfreq` 50 to 20,000 Hz, 256 channels, 512 MB per upload, 120 s per request including the upload. The Python client, CLI and MCP server split longer recordings into several requests (EDF, BDF and FIF files over 32 MiB need `pip install mne` for that). Billing is per window, 30 s of EEG each, so a smaller `stride_seconds` bills more. Full table: [Limits](/api#limits).
**Long recordings over HTTP.** A whole recording of up to 24 h is one job, so a full night needs no splitting: `POST /v1/files` gives an upload URL, PUT the file there, `POST /v1/jobs`, poll `GET /v1/jobs/{id}` every `Retry-After` seconds, then download `result.url` (an `.npy` `[windows, 768]`). A 23 h Sleep-EDF recording (2,802 windows) took 65 s from upload to result. Results are kept 7 days and the upload is deleted when the job ends. The Python client, CLI and MCP server 2.2.0 do not use jobs yet. Steps and limits: [Jobs for long recordings](/api#jobs-for-long-recordings).
## Tested recipe
[Similarity search](/similarity-search) is a complete script that was run as printed, with its results.
# API reference
Source: https://docs.neuroencoder.com/api
REST reference for the Neuroencoder EEG embeddings API: POST /v1/embeddings, channel names, billing, idempotency, limits, keys, errors and the Python client.
Base URL `https://api.neuroencoder.com/v1`. Auth `Authorization: Bearer ` (header only, never in the URL). OpenAPI: [`/v1/openapi.json`](https://api.neuroencoder.com/v1/openapi.json), interactive at [`/v1/docs`](https://api.neuroencoder.com/v1/docs). `POST /v1/embeddings` is deterministic and stores nothing. [Jobs](#jobs-for-long-recordings) keep the upload until the job ends and the result for 7 days.
## POST /v1/embeddings
One 768-d L2-normalised embedding per 30 s window, one window every `stride_seconds`, at most 1,024 windows per request. For a whole night, use a [job](#jobs-for-long-recordings). The [Python client](#python-client) sends longer recordings as several requests and merges the results.
**Multipart**: `file` (`.edf`, `.bdf`, single-file `.fif`, or `.npy` `[channels, samples]`), optional `metadata` JSON object with the fields below. EDF, BDF and FIF take `sfreq` from the header and `channel_names` from the header labels unless you pass names; `.npy` needs `sfreq`.
**JSON**:
**input** `array | string` *required*
`[channels, samples]` nested list (body at most 16 MiB), or a base64 string of little-endian float32 with `shape`. `[n, channels, samples]` is also accepted: each item is one window of at most 30 s.
**shape** `int[]`
`[channels, samples]` or `[n, channels, samples]`. Required with base64 input.
**sfreq** `number` *required*
Sampling rate in Hz, 50 to 20,000.
**channel\_names** `string[]`
10-20 labels, one per channel. See [Channel names](#channel-names). Required unless the input is exactly the 8 regional channels.
**stride\_seconds** `number` (default: 30)
Hop between window starts, 0.5 to 3,600. 30 gives non-overlapping windows; smaller values overlap and bill more (see [Billing](#billing)).
**filter** `boolean` (default: true)
1-100 Hz bandpass and 50/100 Hz notch before resampling to 250 Hz.
**encoding\_format** `string` (default: float)
`float` or `base64` (float32, 4x smaller).
**model** `string` (default: epi-300k)
Only `epi-300k` today.
Optional header `Idempotency-Key`: see [Retries and idempotency](#retries-and-idempotency).
Response for 10 minutes of EEG at the default stride:
```json theme={null}
{"object": "list", "model": "epi-300k",
"channels_used": [{"label": "EEG Fpz-Cz", "region": "Frontal", "bipolar": true},
{"label": "EMG submental", "region": null, "bipolar": false}],
"data": [{"object": "embedding", "index": 0, "t_start": 0.0, "embedding": [0.012, ...]},
{"object": "embedding", "index": 1, "t_start": 30.0, "embedding": [-0.004, ...]}],
"usage": {"seconds_of_eeg": 600.0, "windows": 20, "epochs": 20, "month_seconds_of_eeg": 4200.0}}
```
* `t_start`: start of the window in seconds from the start of the input (the stride is rounded to 4 ms). For `[n, channels, samples]` input every item starts at 0.0.
* `channels_used`: one entry per input channel, in input order. `region` is one of `Frontal`, `Central`, `Temporal_Left`, `Temporal_Right`, `Parietal`, `Occipital`, `EOG`, `ECG`, or `null` if the channel was ignored.
* `usage.seconds_of_eeg`: billed amount, `windows` x 30. `epochs` equals `windows` (kept for 1.x clients). `month_seconds_of_eeg`: this key's total for the current UTC month, including this request.
## Channel names
Channels are averaged into six scalp regions by their 10-20/10-10 labels, so any montage works.
* Labels are matched case-insensitively. `EEG ` and `POL ` prefixes and references (`-REF`, `-LE`, `-AVG`, `-A1`, `-A2`, `-M1`, `-M2`) are stripped: `fp1`, `EEG C3`, `FP1-REF` and `C3-M2` all map to their electrode.
* Bipolar derivations (`FP1-F7`, `EEG Fpz-Cz`) map to the region of the first electrode and are flagged `"bipolar": true`.
* EOG and ECG labels (`EOG horizontal`, `ECG`) are recognised but not used by the model.
* Channels that match no region (`EMG submental`, `Resp oro-nasal`) are ignored and reported with `"region": null`. If no channel maps to a scalp region the request fails with `no_eeg_channels`.
* Without `channel_names` the input must be exactly the 8 regional channels in the order above; any other channel count fails with `missing_channel_names`.
## Windows, short inputs and resampling
Input is resampled linearly to 250 Hz. A recording of at least 30 s gives one window every `stride_seconds`; trailing data shorter than a window is not embedded. Anything from 0.5 s to 30 s is embedded as one window at its true length. `neuroencoder.n_windows(n_samples, sfreq, stride_seconds)` gives the count before you send.
## Billing
The unit is the window, and each window counts as 30 s of EEG. At the default stride of 30 s the billed amount equals the recording length (whole windows); at stride 1 s it is about 30 times that. A recording shorter than 30 s is one window. Only 2xx responses are billed: quota is reserved before compute and refunded if the request fails. Quotas reset at the start of each UTC month.
## Retries and idempotency
Send `Idempotency-Key: <1-128 printable ASCII characters>`, a fresh uuid4 per logical request. The key is bound to the request (the decoded samples, shape, `sfreq`, `stride_seconds`, `filter`, `channel_names` and `model`) and is valid for 24 hours from first use. Retrying with the same key and the same request is never charged twice:
* The first attempt got no response (dropped connection, timeout, gateway error): the retry is processed and charged once in total.
* The first attempt failed with an error: it was not charged; the retry is processed and charged once.
* The first attempt succeeded: the retry is recomputed and returned free of charge with the header `Idempotent-Replayed: true`. This works up to 3 times per key; after that the request is charged as a new one.
* The same key with a different request: 409 `idempotency_key_reused`, not charged.
Nothing is cached: a replay is a recomputation, and the output is deterministic. Only a hash of the request is kept with the key, never the samples. The Python client sends a key with every request and reuses it across its own retries.
## Jobs for long recordings
A job embeds one file of up to 24 h: upload it once, submit, poll, download. A full night is one job: a 23 h Sleep-EDF recording (2,802 windows) took 65 s from the start of the upload to the result. Python client 2.2.0 does not use jobs; it splits long recordings into several requests (see [Python client](#python-client)).
```bash theme={null}
F=night.edf
curl -s https://api.neuroencoder.com/v1/files -H "Authorization: Bearer $NEUROENCODER_API_KEY" \
-H "content-type: application/json" -d "{\"filename\": \"$F\", \"bytes\": $(wc -c < $F)}" > file.json
curl -s -X PUT --upload-file $F "$(jq -r .upload_url file.json)"
curl -s https://api.neuroencoder.com/v1/jobs -H "Authorization: Bearer $NEUROENCODER_API_KEY" \
-H "content-type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d "{\"file\": \"$(jq -r .id file.json)\", \"options\": {\"stride_seconds\": 30}}" > job.json
JOB=$(jq -r .id job.json)
while sleep 5; do
curl -s https://api.neuroencoder.com/v1/jobs/$JOB -H "Authorization: Bearer $NEUROENCODER_API_KEY" > job.json
case $(jq -r .status job.json) in queued|running) ;; *) break ;; esac
done
jq '{status, usage, error}' job.json
curl -s "$(jq -r .result.url job.json)" -o night.npy
```
1. `POST /v1/files` with `filename` and `bytes` (the file size) returns `id`, `upload_url` and `upload_headers`. Only the extension of `filename` is used (`.edf`, `.bdf` or `.npy`); the name is not stored. PUT the file to `upload_url` within 10 minutes with exactly `upload_headers` (`curl --upload-file` sends the `Content-Length`). Send no `Authorization` header there.
2. `POST /v1/jobs` with `file` and optional `options` returns 202 with the job, a `Location` header and `Retry-After: 5`. Options: `stride_seconds` (default 30), `filter` (default true), `channel_names`, `picks` (EDF/BDF only: the header labels to keep) and `sfreq` (required for `.npy`). The submit reads the file header and checks the limits below, so most input errors come back here. With an `Idempotency-Key` header, the same key and body within 24 h return the same job.
3. `GET /v1/jobs/{id}` every `Retry-After` seconds. `status` is `queued` or `running`, then `succeeded`, `failed`, `cancelled` or `expired`. The job also shows `input` (channels, `sfreq`, samples, duration), `usage` and, on failure, `error` with `code`, `message` and `fix`.
4. On `succeeded`, `result.url` is a download link (valid 10 minutes; GET the job again for a fresh one) for an `.npy` float32 array `[windows, 768]`. Window `i` starts at `i * result.t_start_step` seconds. `result.channels_used` is as in `POST /v1/embeddings`.
Other calls: `GET /v1/jobs?limit=20` lists this key's recent jobs, newest first (at most 100). `POST /v1/jobs/{id}/cancel` stops a queued job at once and a running job at the worker's next check (every 5 s). `DELETE /v1/jobs/{id}` deletes the job and its result now; afterwards the job is 404.
**Limits per job**: file at most 2 GB; at most 24 h, 400 M samples (channels x samples), 22.1 M samples per channel (counted at max(`sfreq`, 250 Hz): 24 h at 256 Hz, 12 h at 512 Hz), 256 channels and 8,192 windows. The error's `fix` gives the limit for your file; `picks` or a larger `stride_seconds` help.
**Queue**: jobs from all keys share one queue and start in turn by key, so a key with many jobs does not hold up the others. A key runs at most `active_jobs` jobs at once (see [Keys](#keys)) and can have 20 waiting; more get 429 `too_many_queued_jobs`. A job that has not started within 24 h ends as `expired`.
**Billing and retention**: the quota is reserved at submit (30 s of EEG per window) and refunded if the job fails, is cancelled or expires (`usage.billed` is then false). Job windows do not count toward the windows-per-minute limit. The upload is deleted when the job ends, and a file that no job used after a day. A file serves one job; submitting it again gives 409 `file_already_used`. Results are kept 7 days after the job ends (`expires_at`), then deleted.
Failed jobs carry the input error codes of `POST /v1/embeddings`, or one of these: `worker_timeout` (did not finish within the worker's time limit), `worker_unavailable` and `internal_error` (submit again), `input_changed` (the file was uploaded again after the submit), `job_expired`. None of them is billed.
A job runs on different hardware than `POST /v1/embeddings`, so the vectors can differ in the last digits (cosine 0.9999 or higher between the two on our 10-minute test file).
## Limits
| Limit | Value |
| - | - |
| Upload | 512 MB file (`Content-Length` required, no chunked uploads); `POST /v1/jobs` 64 KB; other POSTs 4 KB |
| JSON nested-list body | 16 MiB; above that send base64 or a `.npy` file |
| Windows per request | 1,024 (8.5 h at stride 30, 17.5 min at stride 1); a [job](#jobs-for-long-recordings) takes 8,192 |
| Duration | at least 0.5 s; at most min(48 h, 1,382,400 s / channels, 160,000,000 / (channels x sfreq) s, 8,000,000 / max(sfreq, 250) s) |
| Pre-epoched items | at most 30 s each |
| Channels | 256, names up to 32 characters |
| `sfreq` | 50 to 20,000 Hz |
| `stride_seconds` | 0.5 to 3,600 |
| Processing | about 15 windows/s; requests are stopped after about 100 s (`deadline_exceeded`, not billed) |
| Whole request | 120 s from the first byte of the upload to the response; a slower request is cut by the gateway with a 502 or 504 that has no JSON body |
Longest input per request, before the window cap: 19 channels at 256 Hz 8.68 h, 8 channels at 250 Hz 8.89 h, 64 channels at 512 Hz 1.36 h, 256 channels at 250 Hz 0.69 h, 1 channel at 20 kHz 400 s. For longer recordings use a [job](#jobs-for-long-recordings). The Python client 2.2.0 splits them into several requests instead.
## Keys
Keys look like `ne_live_` followed by 48 characters (the last 6 are a checksum, so a mistyped key is rejected before any lookup). They are shown once and stored only as a keyed hash. Each key has a tier with a per-minute rate limit and a monthly quota in hours of EEG, an optional expiry, and scopes.
| Tier | Requests / min | Windows / min | Requests in flight | Active jobs | EEG per month | Expires | Who |
| - | -: | -: | -: | -: | -: | - | - |
| trial | 30 | 2,000 | 2 | 1 | 100 h (12,000 windows) | 30 days | self-serve |
| collab | 120 | 6,000 | 4 | 3 | 5,000 h | 180 days | research partners, free |
| paid | 120 | 6,000 | 4 | 3 | 5,000 h by default | never | customers |
Requests per minute count every authenticated call, including job polls. Windows per minute and requests in flight apply to `POST /v1/embeddings` only (429 `windows_rate_limit_exceeded` and `concurrency_limit_exceeded`). Active jobs is how many jobs of a key run at once; more wait in the queue. `GET /v1/me` shows a key's limits.
### Signup
Endpoints: `POST /v1/signup`, `POST /v1/verify`, `POST /v1/signup/token`. No key needed.
The shortest way from a terminal is `neuroencoder login --email you@lab.org` (in the base package), which runs the device flow below and saves the key to `~/.config/neuroencoder/api_key`.
```bash theme={null}
curl -X POST https://api.neuroencoder.com/v1/signup -H "content-type: application/json" -d '{"email": "you@lab.org"}'
```
Sends a single-use link (valid 30 minutes) to that address. The link opens a page with one button; pressing it (a `POST /v1/verify` with the token) returns the key once, plus the tier and limits. Agents with access to the mailbox can skip the page and `POST /v1/verify` with `{"t": ""}` directly.
**Device flow** (terminals and agents, no copy-paste of the key): add `"device": true`. The response carries `device_code` (keep it secret), `user_code` (show it to the human), `interval` (5 s) and `expires_in` (1,800 s). The mail links to a page on this host that shows the code; the human approves only if it matches. Poll `POST /v1/signup/token` with `{"device_code": "..."}` every `interval` seconds: the answer is `{"object": "signup", "status": "pending", "interval": 5}` until the link is approved, then the key exactly once (`object` `api_key`, with `id`, `tier`, `rate_per_min`, `quota_seconds_month`, `expires_at`, `replaced`). Afterwards, and for unknown or expired codes, it answers 400 `invalid_token`. Approving a device signup with `POST /v1/verify` returns `{"status": "approved"}`; the key still goes to the poller. At most 30 polls per minute per IP.
**Replacement.** One active trial key per address. Signing up again while that key is live mails a replacement link instead: the new key has the same limits and expiry, this month's usage carries over, and the old key is revoked when the new one is issued (`replaced: true`). If the address has a collaboration or paid key, the mail says so and no link is sent; the HTTP answer is the same in every case.
**Caps.** Per IP 5 signup requests per minute (429 `rate_limit_exceeded`) and 10 emails per UTC day; per address 5 emails per day; at most 5 new trial keys per email domain per day (replacements do not count). Daily caps answer 429 `too_many_signups` with `Retry-After` until UTC midnight. Malformed addresses are refused with `invalid_email`, disposable domains with `disposable_email`. If the email does not arrive, email [contact@neuroencoder.com](mailto:contact@neuroencoder.com). Collaboration and paid keys are issued by hand; ask at the same address or through the [contact form](https://neuroencoder.com/contact).
### GET /v1/me
Your key's tier, limits and this month's usage: `{"object": "api_key", "id": "...", "prefix": "ne_live_...", "last4": "...", "tier": "trial", "scopes": [...], "rate_per_min": 30, "quota_seconds_month": 360000, "expires_at": 1796774400, "limits": {"requests_per_min": 30, "windows_per_min": 2000, "concurrent_requests": 2, "active_jobs": 1, "quota_seconds_month": 360000}, "usage": {"month": "2026-11", "seconds_of_eeg": 1234.5, "requests": 12, "quota_remaining_seconds": 358765.5}}`. `quota_remaining_seconds` is null for a key without a monthly quota.
If a key leaks, tell us or ask for a roll: the new key is issued immediately and the old one keeps working for 12 hours.
## GET /v1/models
Lists `epi-300k` with its dimensions (768), window (30 s), sample rate (250 Hz) and `max_windows_per_request` (1,024).
## Headers
Every response carries `x-request-id` (quote it when reporting a problem) and `x-processing-ms`. Authenticated responses also carry `x-ratelimit-limit-requests`, `x-ratelimit-remaining-requests` and `x-ratelimit-reset-requests`; a 429 `concurrency_limit_exceeded` adds `x-ratelimit-limit-concurrency`. The API's own 429 and 503 errors carry `Retry-After` in seconds. A 429 from the gateway or firewall in front of the API has no JSON `code` and may have no `Retry-After`: retry it with exponential backoff and jitter.
## Errors
Every error is `{"error": {"message", "type", "code", "param", "fix"}}`. `param` names the offending field; `fix` says what to change.
| Status | Code | Meaning | What to do |
| - | - | - | - |
| 400 | `invalid_input` | A field is out of range or malformed; `param` says which | Follow `fix` |
| 400 | `missing_input`, `missing_shape`, `missing_sfreq` | A required field is absent | Add it |
| 400 | `missing_channel_names` | No names and the input is not the 8 regional channels | Pass `channel_names` |
| 400 | `no_eeg_channels` | No channel maps to a scalp region | Use 10-20 labels |
| 400 | `too_many_windows` | More than 1,024 windows (8,192 for a job) | Use a job, raise `stride_seconds` or split; `fix` gives the numbers |
| 400 | `input_too_large` | Over the duration or sample limits | Use a job, split the recording or drop channels; `fix` gives the limit |
| 400 | `unsupported_file` | Not `.edf`, `.bdf`, `.fif` or `.npy`, or a split FIF; jobs take `.edf`, `.bdf` and `.npy` only | Export as EDF or a single FIF |
| 400 | `file_too_large`, `file_not_uploaded` | Jobs: file over 2 GB; upload missing or of a different size than `bytes` | Drop channels; PUT the file to `upload_url`, then submit |
| 400 | `unsupported_task` | Jobs: `task` is not `embeddings` | Omit `task` |
| 400 | `unreadable_file` | The file could not be parsed or is truncated | Re-export, or send `.npy` with `sfreq` |
| 400 | `invalid_body`, `invalid_idempotency_key` | Body is not JSON or multipart; bad `Idempotency-Key` | Fix the request |
| 400 | `invalid_email`, `disposable_email`, `invalid_token` | Signup: address refused; link or `device_code` unknown, used or older than 30 minutes | Use a real address; start again with `POST /v1/signup` |
| 401 | `invalid_api_key` | Key missing, malformed, revoked or expired; `fix` says which and how to get a key | Check `NEUROENCODER_API_KEY`, or get a key ([Signup](#signup)) |
| 403 | `insufficient_scope` | Key lacks the scope for this endpoint | Ask for a key with that scope |
| 404 | `model_not_found`, `not_found` | Unknown model or path | Use `epi-300k`; see the OpenAPI spec |
| 404 | `file_not_found`, `job_not_found` | Unknown, expired or deleted file or job, or one of another key | Use an id from `POST /v1/files` or `GET /v1/jobs` |
| 409 | `idempotency_key_reused` | The `Idempotency-Key` was already used for a different request | Use a fresh key per logical request |
| 409 | `idempotency_key_in_use` | Jobs: the first request with this `Idempotency-Key` is still being processed | Retry after `Retry-After` |
| 409 | `file_already_used` | Jobs: the file was already submitted; files serve one job | Upload it again |
| 411 | `length_required` | No `Content-Length` | Send the body with a length, not chunked |
| 413 | `request_too_large` | Body over 512 MB, or a JSON list over 16 MiB | Use a job, split, or send base64 / `.npy` |
| 422 | `invalid_input` | A multipart field has the wrong form, e.g. `metadata` sent as a file | Send `metadata` as a text field |
| 429 | `rate_limit_exceeded` | Too many requests this minute | Wait `Retry-After`; the Python client does |
| 429 | `concurrency_limit_exceeded` | More `POST /v1/embeddings` requests in flight than the key allows | Wait `Retry-After` (2 s) |
| 429 | `windows_rate_limit_exceeded` | More windows this minute than the key allows | Wait `Retry-After`, or use a job |
| 429 | `too_many_queued_jobs` | 20 jobs of this key are already waiting | Wait for some to start, or cancel some |
| 429 | `quota_exceeded` | Monthly quota used up | Wait for the next UTC month (`Retry-After`) or ask for more |
| 429 | `too_many_auth_failures` | More than 20 failed authentications from this IP in a minute | Fix the key, then wait `Retry-After` |
| 429 | `too_many_signups` | Daily signup cap for this IP or address | Wait `Retry-After` (until UTC midnight) |
| 429 | none (no JSON `code`) | The gateway or firewall in front of the API | Back off exponentially with jitter |
| 500 | `internal_error` | Server fault | Retry; report the request id in `fix` |
| 503 | `overloaded` | Busy with another request | Retry after `Retry-After` (1 to 60 s); the Python client does |
| 503 | `jobs_unavailable` | Jobs are switched off on this server | Use `POST /v1/embeddings` |
| 504 | `deadline_exceeded` | Not finished within about 100 s; not billed | Use a larger stride or a shorter recording |
| 502, 504 | none (no JSON body) | The gateway cut the request at 120 s, usually a slow upload | Send smaller pieces; retry with the same `Idempotency-Key` |
## Python client
```python theme={null}
from neuroencoder import embeddings
r = embeddings(input, sfreq=None, channel_names=None, stride_seconds=30, filter=True, model="epi-300k")
r.data[i].embedding, r.data[i].t_start, r.to_numpy(), r.t_start, r.channels_used, r.usage.windows
from neuroencoder import Neuroencoder # explicit configuration
Neuroencoder(api_key=None, base_url=None, timeout=600, max_retries=2).embeddings.create(...)
```
`input` is an array or tensor `[channels, samples]` (or `[n, channels, samples]`), an `mne` Raw, or a path or open file (`.edf`, `.bdf`, `.fif`, `.npy`). `embeddings` always uses the hosted API; the key comes from `api_key=`, `NEUROENCODER_API_KEY` or the file saved by `neuroencoder login`.
**Long recordings.** Version 2.2.0 does not use [jobs](#jobs-for-long-recordings). It sends long inputs as consecutive requests of at most 256 windows and 32 MiB each and merges the results, so one call returns the same windows, `t_start` and `usage` as a single request would, with no window limit per call. Pieces are embedded independently, so the window on each side of a piece boundary can differ slightly from a single request (cosine 0.9986 or higher on two full Sleep-EDF recordings, typically above 0.9996); all other windows are identical. Each piece is billed as it completes; if one fails after retries, the error says how many requests and windows were already billed. EDF, BDF and FIF files over 32 MiB are read locally with `mne` (`pip install mne`) and split the same way; without `mne` they are uploaded whole and the per-request limits apply.
Errors raise `AuthenticationError` (401, 403, no key), `RateLimitError` (429), `APIStatusError` (other statuses) or `InvalidInputError` (checks done before sending), each with `.status`, `.code`, `.param` and `.fix`. The client retries up to `max_retries` times on connection failures, 503 and 429 `rate_limit_exceeded`, waiting `Retry-After` (at most 60 s). Every embeddings request carries an `Idempotency-Key`, so it also retries timeouts, connections lost after sending and 500/502/504 with the same key, and the server does not bill a repeated request twice. `quota_exceeded` and other 4xx are never retried. Other 429s (`concurrency_limit_exceeded`, `windows_rate_limit_exceeded`, a gateway 429 without a JSON code) raise `RateLimitError`: wait `Retry-After`, or back off with jitter if there is none, and call again. An error that did not come from the API itself (for example the gateway's 120 s limit) has `code` `gateway_error` and says so in its message.
# Benchmarks
Source: https://docs.neuroencoder.com/benchmarks
Benchmarks of frozen EEG foundation model embeddings with linear probes on clinical and public EEG tasks (seizure, spike, artifact, sleep, TUH).
Frozen linear probes, 5-fold subject-level cross-validation. Balanced accuracy (%).
These tables are from the previous generation: **EPI-250k** (base model) and its retired Matryoshka distillation at each truncation dimension. The model behind the API today, **EPI-300K** (pre-trained on 250,000 hours of clinical EEG plus 50,000 hours of synthetic seizure data), scores a 3-seed mean of 72.5% balanced accuracy across the nine internal clinical tasks; its full per-task table ships with the paper.
## Private clinical tasks
40,909 annotated 30-second epochs from the Swiss Epilepsy Center.
| Task | EPI-250k | 768 | 384 | 192 | 48 | 16 |
| - | :-: | :-: | :-: | :-: | :-: | :-: |
| Seizure / Wake | **93.4** | 93.1 | 92.7 | 92.5 | 91.5 | 84.1 |
| Sleep (5-class) | **85.1** | 77.0 | 77.4 | 76.9 | 76.5 | 73.2 |
| Artifact / Wake | **90.2** | 90.5 | 90.3 | 90.5 | 90.7 | 65.9 |
| Seizure / Sleep | **88.8** | 85.2 | 84.9 | 84.0 | 82.1 | 79.4 |
| Spike / Seizure | **81.5** | 76.2 | 75.9 | 74.7 | 71.0 | 65.5 |
| Spike / Wake | **97.0** | 94.8 | 94.7 | 94.6 | 92.9 | 87.2 |
| Artifact / Spike | **78.8** | 76.0 | 75.6 | 75.3 | 74.4 | 70.4 |
| Category (6-cls) | **36.3** | 33.6 | 33.3 | 32.8 | 31.7 | 27.4 |
| Clinical Sub (7-cls) | **42.7** | 31.4 | 31.4 | 31.4 | 27.0 | 23.7 |
| All Sublabels (49-cls) | **22.1** | 14.8 | 14.4 | 13.7 | 12.3 | 10.6 |
## Public benchmarks
10 standard public EEG datasets, evaluated under identical conditions.
| Task | EPI-250k | 768 | 384 | 192 | 48 | 16 |
| - | :-: | :-: | :-: | :-: | :-: | :-: |
| TUAB | **73.1** | 72.4 | 72.5 | 72.9 | 72.2 | 70.4 |
| TUEV | **54.5** | 45.9 | 47.2 | 46.7 | 42.8 | 32.1 |
| TUAR | **45.2** | 43.0 | 42.9 | 42.2 | 39.5 | 36.5 |
| TUSL | **73.3** | 71.5 | 75.1 | 77.1 | 71.3 | 69.7 |
| Mumtaz | **82.1** | 80.7 | 81.8 | 82.6 | 83.2 | 83.1 |
| Schizo | **71.1** | 70.1 | 69.4 | 69.5 | 69.4 | 66.7 |
| MentArith | **60.9** | 60.2 | 59.9 | 58.6 | 55.6 | 52.2 |
| ADFTD | **43.2** | 40.0 | 40.0 | 41.0 | 38.6 | 35.9 |
| PhysioMI | **30.3** | 28.3 | 28.4 | 27.3 | 27.7 | 25.2 |
| Parkinsons | **62.9** | 58.9 | 58.6 | 58.2 | 55.9 | 53.2 |
The numeric column headers (`768`, `384`, ...) are the truncation dimensions of the retired distilled model.
## Dimension retention
Mean delta vs the EPI-250k base model, across all 20 tasks.
| Truncation | Mean delta |
| :-: | :-: |
| 768 | -3.4 pp |
| 384 | -3.3 pp |
| 192 | -3.5 pp |
| 48 | -5.3 pp |
| 16 | -10.0 pp |
Binary tasks retain accuracy best. Fine-grained multi-class tasks (TUEV, sublabels) and tasks with large domain shift from pre-training data (Parkinsons, MI) degrade more sharply.
# EEG embeddings API
Source: https://docs.neuroencoder.com/introduction
Neuroencoder is a hosted EEG embeddings API and Python package. Send a recording and get one 768-dimensional vector per 30-second window from the EPI-300K EEG foundation model.
Neuroencoder is a hosted embeddings API and Python package for EEG: send a recording, get one 768-dimensional vector per 30-second window from EPI-300K, an EEG foundation model pre-trained on 250,000 hours of clinical EEG.
```bash theme={null}
pip install neuroencoder
```
The training set is 250,000 hours of clinical EEG plus 50,000 hours of synthetic seizure data. Embeddings are L2-normalised. Any montage, any sampling rate; channels are mapped by their 10-20 names. [Neuroencoder](https://neuroencoder.com) is a collective of projects on machine learning for neural data; this site documents the embeddings API.
```python theme={null}
from neuroencoder import embeddings
r = embeddings(eeg, sfreq=256, channel_names=ch_names) # hosted API; key from `neuroencoder login` or NEUROENCODER_API_KEY
r.to_numpy() # [n_windows, 768]
```
> [!NOTE]
>
> Research use only. The model is not a medical device and must not be used for diagnosis, treatment or monitoring.
> [card] [Quickstart](/quickstart)
>
> Key, first call, files
> [card] [API reference](/api)
>
> Endpoint, parameters, errors
> [card] [Similarity search](/similarity-search)
>
> Tested script: nearest EEG windows across subjects
> [card] [For AI agents](/agents)
>
> llms.txt, skill file, OpenAPI, error contract
> [card] [Benchmarks](/benchmarks)
>
> Clinical and public tasks
# Get EEG embeddings in Python
Source: https://docs.neuroencoder.com/quickstart
Install the neuroencoder Python package, get an API key and turn an EEG recording (EDF, BDF, FIF or NumPy) into 768-dimensional embeddings with the EPI-300K foundation model.
**1. Install and get a key**
```bash theme={null}
pip install neuroencoder
neuroencoder login --email you@lab.org
```
This prints a code and emails a link; open it within 30 minutes and approve if the page shows the same code. The key (trial: 100 h of EEG per month, 30 days) is saved to `~/.config/neuroencoder/api_key` and found automatically. Check it with `neuroencoder whoami`.
Without the command line: `curl -X POST https://api.neuroencoder.com/v1/signup -H "content-type: application/json" -d '{"email": "you@lab.org"}'` emails a single-use link that shows the key once; then `export NEUROENCODER_API_KEY=ne_live_...`. If the email does not arrive, or you need a larger or longer key for a collaboration, email [contact@neuroencoder.com](mailto:contact@neuroencoder.com) (or use the [contact form](https://neuroencoder.com/contact)).
**2. Send a recording**
EDF, BDF, single-file FIF and `.npy` are read server-side; nothing is stored. Channel names come from the file header.
```python theme={null}
from neuroencoder import embeddings
r = embeddings("recording.edf") # path, open file, or an mne Raw
emb = r.to_numpy() # [n_windows, 768], L2-normalised
t0 = r.t_start # window start in seconds
r.channels_used # region each channel was mapped to (None = ignored)
r.usage # windows, billed seconds_of_eeg, this month's total
```
**3. Or pass an array**
Any number of channels and any sampling rate. Channel names are 10-20 labels.
```python theme={null}
import mne
from neuroencoder import embeddings
raw = mne.io.read_raw_edf("recording.edf", preload=True)
r = embeddings(
raw.get_data(), # [channels, samples]
sfreq=raw.info["sfreq"],
channel_names=raw.ch_names,
stride_seconds=30, # one window per 30 s (default); smaller values overlap and bill more
)
```
Recordings of any length work: the client sends long ones as several requests and merges the results (see [Python client](/api#python-client)). `embeddings(...)` is the same as `embeddings.create(...)`. For explicit configuration use `Neuroencoder(api_key=..., base_url=...).embeddings.create(...)`.
## Command line
```bash theme={null}
neuroencoder inspect night.edf # free, no upload: channels -> regions, windows and seconds embed would bill
neuroencoder embed night.edf -o night.npz # embeddings [n, 768], t_start, meta in one .npz
neuroencoder --json embed night.edf -o night.npz # one JSON object on stdout, for scripts and agents
```
Read the result with `z = np.load("night.npz")`: `z["embeddings"]`, `z["t_start"]`, `json.loads(str(z["meta"]))`. For agents there is also a local MCP server; see [For AI agents](/agents).
## Visualise
Needs `pip install 'neuroencoder[viz]'`.
```python theme={null}
import neuroencoder as ne
ne.explore(r, labels=stages) # Jupyter widget; stages: one label per window, optional
ne.export(r, "atlas") # standalone web app to share
```
## Common patterns
```python theme={null}
import numpy as np
sim = emb @ emb.T # cosine similarity (already normalised)
nearest = np.argsort(-sim[0])[1:6] # 5 windows most similar to the first
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(max_iter=200).fit(emb[train], y[train]) # linear probe
```
## curl
```bash theme={null}
curl https://api.neuroencoder.com/v1/embeddings \
-H "Authorization: Bearer $NEUROENCODER_API_KEY" \
-F file=@recording.edf -F 'metadata={"stride_seconds": 30}'
```
## Errors
Every error is JSON with a `fix` field that says what to change:
```json theme={null}
{"error": {"message": "sfreq is required with .npy input.", "type": "invalid_request_error",
"code": "missing_sfreq", "param": "sfreq", "fix": "Pass metadata={\"sfreq\": 256, \"channel_names\": [...]} with the file."}}
```
All codes are listed in the [API reference](/api#errors).
# EEG similarity search with embeddings
Source: https://docs.neuroencoder.com/similarity-search
Find the most similar 30 s EEG windows across subjects with EPI-300K embeddings and plain NumPy cosine search. Tested on 20 Sleep-EDF nights, with the same-stage rate of the nearest neighbours against chance.
Each 30 s window becomes one L2-normalised 768-d vector, so a matrix product is a cosine-similarity search. This recipe embeds 20 nights of public sleep EEG, queries one window, returns its nearest windows from other people, and measures how often those neighbours carry the same scored sleep stage.
> [!NOTE]
>
> Research demonstration, not clinical validation. Sleep stages are used here only as an outside label to check what the neighbours have in common.
## Data and protocol
* **Dataset**: [Sleep-EDF Expanded](https://physionet.org/content/sleep-edfx/1.0.0/), sleep-cassette study, first night of subjects 0 to 19 (`SC4001` to `SC4191`, PSG and hypnogram files). Check the downloads against PhysioNet's `SHA256SUMS.txt`: a truncated EDF still opens.
* **Signal**: the two EEG channels, Fpz-Cz and Pz-Oz, at 100 Hz as stored. Each night is cropped to 30 minutes before the first and after the last sleep epoch.
* **Windows**: the default stride of 30 s, so windows line up with the hypnogram's 30 s epochs. 20,650 windows were embedded; 20,626 have a stage (W, N1, N2, N3, REM; stages 3 and 4 merged into N3). The 24 windows scored as movement or unknown are left out of the search.
* **Search**: cosine similarity over all labelled windows, with every window of the query's own night masked, so neighbours always come from another subject. K = 10.
* **Chance**: for a query of stage s, the fraction of other-subject windows that have stage s. This is the same-stage rate of 10 neighbours drawn at random.
* **Run**: 10 November 2026, `neuroencoder` 2.1.0, hosted API, Python 3.11.
## 1. Embed the nights
```python embed_nights.py theme={null}
import glob, os, time
import numpy as np, mne
from neuroencoder import embeddings
DATA, CACHE = "sleep-cassette", "cache" # Sleep-EDF Expanded sleep-cassette files; one output file per night
NIGHTS = [f"SC4{s:02d}1" for s in range(20)] # first night of subjects 0-19
HYPNO = {"Sleep stage W": "W", "Sleep stage 1": "N1", "Sleep stage 2": "N2", "Sleep stage 3": "N3",
"Sleep stage 4": "N3", "Sleep stage R": "REM"} # Movement time and "?" get no label
mne.set_log_level("ERROR"); os.makedirs(CACHE, exist_ok=True)
for night in NIGHTS:
raw = mne.io.read_raw_edf(glob.glob(f"{DATA}/{night}E0-PSG.edf")[0], preload=True)
n = int(raw.n_times / raw.info["sfreq"] // 30)
y = np.full(n, "", dtype="