Resources › Platform SDK
Platform SDK
Stream data from the Focus+ band into your own product, and get processed brain metrics back in under a second.
What is the Platform API?
You distribute the Focus+ band and own the product experience. Brain-Life runs the signal processing, the models, and the analytics behind them — reachable through one REST API and one WebSocket.
Your application reads raw ADC samples off the band over Bluetooth and pushes them to us. We filter, extract features, run the classifiers, and push results back per second. You never implement signal processing, and you never train a model.
| Brain-Life provides | You provide |
|---|---|
| Signal filtering and feature extraction | The application |
| Focus, relaxation, power bands | User experience |
| AI models and version management | Business logic |
| Session storage and insights | Your end users |
This page refers to Platform API v1 and BLE protocol v2.0 (firmware v0.4.0).
How integration works
Your end users never create a Brain-Life account. You identify them with an external_user_id — any opaque string you choose. We store no name, no email, nothing that identifies a person.
This is deliberate rather than a simplification. Holding no personal data for your users means the legal obligation sits where the actual relationship is: with you.
An email address or real name there moves personal data into Brain-Life systems, raising obligations for both parties. Use an internal identifier that means nothing outside your own database.
Each band you receive is registered to your organization. A band that has not been assigned cannot send data — this stops a unit lost in shipping, or resold on the grey market, from consuming platform resources on your account.
Before you begin
Three things, all issued by your Brain-Life contact:
An API key
Format bl_sk_…. Shown once at creation and stored only as a hash — if you lose it, rotate rather than recover.
At least one registered band
Bound to your organization_id. Check with GET /v1/devices.
Your environment base URL
Staging and production are separate, with separate keys.
The demo application runs in --simulate mode, generating protocol-shaped packets at the real device cadence. Most partners finish their integration before bands arrive.
Quickstart
The complete integration is four calls. Everything after this section is detail.
Open a session
The server assigns both the session id and a single-use realtime ticket, saving a round trip.
POST /v1/sessions
Authorization: Bearer bl_sk_…
{
"external_user_id": "partner-user-12345",
"device_id": "dev_a71f…",
"metadata": { "program": "focus-training-week-3" }
}
→ {
"session_id": "ses_3f9c…",
"ticket": "rt_a91f…",
"expires_in": 60
}
Open the realtime connection
The ticket is valid for 60 seconds and exactly one connection.
wss://api.brainlife.tech/v1/realtime?token=rt_a91f…
Stream
Push batches of raw samples up. Focus scores, power bands, and device state arrive asynchronously.
End the session
Starts summary computation. Idle sessions close automatically after 10 minutes, so a client crash is recoverable.
POST /v1/sessions/ses_3f9c…/end
metadata is free space for your own context — which exercise, which week. We store it and return it untouched. The same rule applies: no personal data.
API keys & tickets
There are two credentials, and the distinction matters.
Your API key authenticates REST calls through an Authorization header. It never appears in a URL.
Browsers cannot set headers on a WebSocket, so the realtime token travels in the query string — where it is written to the access log of every proxy along the path. That is precisely why it is a 60-second single-use ticket rather than your key: even if logged, it is worthless moments later.
POST /v1/realtime/ticket # for reconnects
Authorization: Bearer bl_sk_…
→ { "ticket": "rt_a91f…", "expires_in": 60 }
Scopes
| Scope | Grants |
|---|---|
ingest:write | Push signal data, open and end sessions |
realtime:read | Open a realtime connection |
sessions:read | Read your historical sessions |
insights:read | Read aggregated trends |
devices:manage | Register and activate bands |
raw:read | Download raw EEG/PPG |
Treat this as routine rather than an error: fetch a new ticket and reconnect with exponential backoff. Idle connections close sooner. Periodic re-authorization is what makes key revocation take effect on connections that are already open.
Session lifecycle
A session is one continuous recording for one end user. Every sample belongs to exactly one session.
The server issues it. This is what prevents one partner writing into another partner's session, so a client-supplied id is rejected rather than honoured.
| Rule | Value | What happens |
|---|---|---|
| Maximum duration | 4 hours | Session auto-ends; open a new one |
| Idle timeout | 10 minutes | Session auto-ends |
| Too short to analyse | — | session.failed webhook |
Calling /end explicitly is recommended but not required — dropped connections are ordinary, and the system does not depend on a clean shutdown.
Sending signal
{
"type": "signal",
"session_id": "ses_3f9c…",
"timestamp": 1785312000123,
"eeg": {
"AF3": [4102, 4098, 4110, 4105],
"AF4": [4088, 4091, 4085, 4093]
},
"ppg": [2048, 2051, 2049, 2047],
"sample_rate": 128
}
| Field | Meaning |
|---|---|
timestamp | Time of the first sample in the batch, epoch milliseconds |
eeg | Raw ADC counts per channel, unfiltered |
ppg | Raw ADC counts from the photoplethysmography sensor |
sample_rate | Hz — must match the device configuration |
Channel names follow the international 10-20 convention, so existing EEG tooling reads them without translation.
One message per sample at 128 Hz means 128 messages per second per user. WebSocket framing then costs more than the payload itself, and it exhausts your message quota quickly.
Our engine applies DC-blocking and bandpass filtering calibrated to this hardware. Filtering first stacks two chains and skews classification in a way that is nearly impossible to trace back to your code.
Receiving results
The connection is bidirectional. These messages travel server to client.
Focus
{
"type": "focus",
"state": "focused", // focused | relaxed | neutral
"confidence": 0.87, // 0.0 – 1.0
"session_id": "ses_3f9c…",
"external_user_id": "partner-user-12345",
"timestamp": 1785312000456
}
Power bands
Emitted once per second.
{
"type": "powerbands",
"channels": {
"AF3": { "delta": 12.4, "theta": 8.1, "alpha": 15.7, "beta": 9.2, "gamma": 3.1 },
"AF4": { "delta": 11.8, "theta": 7.9, "alpha": 16.2, "beta": 8.8, "gamma": 2.9 }
},
"unit": "uV^2/Hz",
"timestamp": 1785312000123
}
Device state
{
"type": "device",
"connected": true,
"contact_quality": { "AF3": "good", "AF4": "poor" },
"battery": 0.72
}
Without it you cannot distinguish the user is calm from the electrode came loose. Both produce a flat signal. A neurofeedback product that confuses the two gives users precisely backwards feedback, and nobody on either side will understand why.
Close codes
Each situation has its own code. Codes marked No are configuration errors on your side — retrying adds load and delays the moment someone finds the real cause.
| Code | Meaning | Retry | Action |
|---|---|---|---|
1000 | Normal close | Yes | Reconnect |
1009 | Message exceeds size limit | No | Reduce batch size |
4001 | Ticket invalid, used, or expired | Yes | Fetch a new ticket |
4002 | Authentication not completed in time | Yes | Fetch a new ticket |
4003 | Insufficient scope | No | Contact Brain-Life |
4004 | Device does not belong to you | No | Check device_id |
4005 | Device revoked | No | Reactivate the band |
4006 | API key revoked or expired | No | Contact Brain-Life |
4008 | Concurrent connection limit reached | Yes | Back off, then retry |
4009 | No active session | Yes | Open a session first |
BLE protocol
Connect over GATT and subscribe to the streaming characteristics, then send the start command. Subscribing after the device has begun streaming loses whatever it emitted in between.
Brainlife Streaming Service
Service UUID 82e0d5f0-9462-4867-8bf3-70c9aea8e878
| Characteristic | UUID | Properties |
|---|---|---|
| Sensor Control | 82e0d5f1-9462-4867-8bf3-70c9aea8e878 | READ, WRITE |
| EPC Control | 82e0d5f2-9462-4867-8bf3-70c9aea8e878 | READ, WRITE |
| EEG AF3 | 82e0d5f3-9462-4867-8bf3-70c9aea8e878 | NOTIFY |
| EEG AF4 | 82e0d5f4-9462-4867-8bf3-70c9aea8e878 | NOTIFY |
| PPG / fNIRS CH1 | 82e0d5f5-9462-4867-8bf3-70c9aea8e878 | NOTIFY |
| fNIRS CH2 | 82e0d5f6-9462-4867-8bf3-70c9aea8e878 | NOTIFY |
Sensor Control sends configuration commands to the EPC chips and reads their configuration back. EPC Control starts and stops acquisition:
| Command | Value |
|---|---|
| START ALL SENSORS | 0x01 |
| STOP ALL SENSORS | 0x02 |
Full GATT service map
| Service | UUID | Type | Purpose |
|---|---|---|---|
| Brainlife Streaming | 82e0d5f0-… | Custom | Sensor control and data |
| Brainlife Control | 80896cd0-… | Custom | LED, vibration motor |
| Brainlife Factory | 810fb410-… | Custom | Factory firmware only — absent on customer units |
| Device Information | 0x180A | Standard | Model, serial, firmware, hardware, PnP ID |
| Battery | 0x180F | Standard | Level and charge status |
| Generic Attribute | 0x1801 | Standard | Service Changed, database hash |
| Generic Access | 0x1800 | Standard | Device name, appearance |
| SMP | 8d53dc1d-… | Custom | Pairing, bonding, encryption, secured OTA |
Generic Attribute indicates on Service Changed (0x2A05) when the service list changes — typically after OTA. Cached handles are stale from that moment on. Clients that cache and ignore the indication read from the wrong handles and fail in ways that look random.
Device Information Service
All characteristics are READ-only UTF-8 strings, except PnP ID.
| Characteristic | UUID | Example value |
|---|---|---|
| Manufacturer Name | 0x2A29 | Brainlife |
| Model Number | 0x2A24 | Focus+ |
| Serial Number | 0x2A25 | DUMMY_SN |
| Firmware Revision | 0x2A26 | 0.9.9+0 |
| Hardware Revision | 0x2A27 | devkit, revB |
| PnP ID | 0x2A50 | Vendor ID, product ID, version |
Battery & charge state
The standard Battery Service (0x180F) exposes two characteristics. Most integrations read only the first and miss the one that actually explains what the battery is doing.
Battery Level — 0x2A19
READ and NOTIFY. One byte, uint8, charge level 0–100%.
Battery Level Status — 0x2BED
READ and NOTIFY. Three bytes: one flags byte followed by a 16-bit Power State field.
Byte 0 — Status Flags. Indicates which fields are present.
| Bit | Meaning |
|---|---|
| 0 | Identifier present |
| 1 | Battery level present |
| 2 | Additional status present |
| 3–7 | Reserved, all zero |
Bytes 1–2 — Power State. This is where charge behaviour lives.
| Bits | Field | Values |
|---|---|---|
| 0 | Battery present | 0 no · 1 yes |
| 1–2 | Wired power connected | 0 no · 1 yes · 2 unknown |
| 3–4 | Wireless power connected | 0 no · 1 yes · 2 unknown |
| 5–6 | Charge state | 0 unknown · 1 charging · 2 discharging active · 3 discharging inactive |
| 7–8 | Charge level | 0 unknown · 1 good · 2 low · 3 critical |
| 9–11 | Charging type | 0 none · 1 constant current · 2 constant voltage · 3 trickle · 4 float |
| 12–14 | Charging fault | 12 battery · 13 external source · 14 other |
| 15 | Reserved | — |
Battery Level alone cannot distinguish discharging from charging, or a healthy cell from a charging fault. Subscribe to Battery Level Status as well and read bits 5–6 before you show a low-battery warning — telling a user to charge a band that is already charging is a support ticket you can avoid.
Both characteristics support NOTIFY, so subscribe rather than poll.
Packet format
Every data point is ten bytes.
The header identifies the channel, the tail is always 0x0A, and the payload is a little-endian signed integer of raw ADC counts.
| Header | Channel |
|---|---|
0x24 | EEG AF4 |
0x25 | PPG channel 1 |
0x26 | EEG AF3 |
0x27 | fNIRS channel 1 |
0x28 | fNIRS channel 2 |
One streaming packet carries 21 data points — 210 bytes.
Point order and channel mix vary between packets. Always read the header byte. Code that assumes a fixed layout passes testing and corrupts data in the field.
LED & haptics
The Brainlife Control Service (80896cd0-8a11-4967-a419-66e4316e22b2) drives the on-device LED and vibration motor. Both are available in customer firmware.
LED
Characteristic 80896cd1-…, one byte: 0x00 red, 0x01 green, 0x02 off.
Vibration motor
Characteristic 80896cd2-…, four bytes — state, duty cycle 1–100, then duration as a little-endian uint16 in milliseconds (1–60000).
on, 50% duty, 1000 ms → 01 32 E8 03
on, 100% duty, 5000 ms → 01 64 88 13
off → 00 00 00 00
When the state byte is 0x00 the device stops immediately and ignores the remaining three bytes.
Services you will not see
The Factory Service (810fb410-b52d-4c48-b94e-3396e1c73e73) provides diagnostics and provisioning — reading and writing device internals such as MAC address and serial number, and a ship-mode command that powers the unit down. It is exposed only in factory firmware and is removed from customer units. If your discovery code finds it, you are holding a factory device and should not ship against it.
The SMP Service (8d53dc1d-1db7-4cd3-868b-8a527460aa84) carries pairing, bonding, encryption, and secured OTA. Your BLE stack drives it; you do not write to it directly.
REST reference
Every endpoint verifies the key, the quota, the scope, and finally that the record belongs to your organization. That last check never trusts a caller-supplied parameter.
| Method | Path | Returns | Scope |
|---|---|---|---|
| POST | /v1/sessions | session_id + ticket | ingest:write |
| POST | /v1/sessions/{id}/end | — | ingest:write |
| POST | /v1/realtime/ticket | Single-use ticket | realtime:read |
| GET | /v1/users/{uid}/sessions | Session list, paged | sessions:read |
| GET | /v1/users/{uid}/sessions/{id} | One session | sessions:read |
| GET | /v1/users/{uid}/sessions/{id}/brainwave | Per-second bandpower | sessions:read |
| GET | /v1/users/{uid}/sessions/{id}/timeline | Confidence + classification | sessions:read |
| GET | /v1/users/{uid}/sessions/{id}/raw | Raw EEG/PPG | raw:read |
| GET | /v1/users/{uid}/insights/{kind} | Cross-session trends | insights:read |
| DELETE | /v1/users/{uid} | Erases that user everywhere | sessions:read |
| GET | /v1/devices | Your registered bands | devices:manage |
| POST | /v1/devices/{id}/activate | Bind a band to an end user | devices:manage |
Pagination
GET /v1/users/{uid}/sessions?limit=50&cursor=eyJ0…
→ { "items": [...], "next_cursor": "eyJ0…" }
Cursors, not page numbers. Page numbers break on time-series data: users keep producing sessions, so boundaries shift between calls and you both miss and duplicate records.
The DELETE endpoint satisfies an end user's erasure request. It removes data across Postgres, object storage, and cache — not a soft-delete flag.
Rate limits & quota
Every limit is scoped to your organization, never global, so another partner's traffic spike cannot exhaust your capacity. You also hold a reserved minimum that stays available under platform-wide load.
| Limit | Applies per |
|---|---|
| Concurrent connections | Organization |
| Connections per end user | Organization |
| Messages per second | Connection |
| Ingest throughput | Organization |
Realtime usage is metered by connection time, not message count. A 30-minute session produces roughly 1,800 messages regardless of what you do — the device sets that rate, so charging per message would penalize normal use.
At 80% of quota we send a quota.threshold webhook. You should never discover a limit by having your service stop.
Webhooks
For events that suit neither the socket nor polling.
| Event | Fires when |
|---|---|
session.completed | Summary data is ready |
session.failed | Signal unusable or connection lost |
device.activated | Band activated for the first time |
device.offline | Band offline past the threshold |
quota.threshold | You reach 80% or 100% of quota |
data.expiring | 7 days before data ages out |
Every delivery is signed with HMAC-SHA256 using a secret unique to your organization:
X-BrainLife-Timestamp: 1785312000
X-BrainLife-Signature: sha256=<HMAC(secret, "1785312000." + body)>
Signing the body alone is not enough — a captured webhook could be replayed indefinitely and still verify. Sign over timestamp.payload and reject anything skewed more than five minutes. Without verification, anyone who learns your webhook URL can post fabricated events.
Store the event_ids you have processed and skip duplicates. A retry can redeliver an event whose acknowledgement was lost in transit, so duplicates are ordinary traffic rather than a sign of attack. Failed deliveries retry with exponential backoff for 24 hours.
Data retention
| Data | Retained | Reasoning |
|---|---|---|
| Raw EEG/PPG | 30 days | Largest by far; rarely read after a few days |
| Per-second power bands | 12 months | Covers a year of trend analysis |
| Session summaries & insights | Indefinite | Small, and the part used long term |
Raw signal is thousands of times larger than summary data while its usefulness drops sharply within days. If you need it longer, download it inside the 30-day window through the raw endpoint — which puts the data somewhere you fully control — or ask about extended storage.
A data.expiring webhook fires seven days ahead, so nothing disappears unannounced.
Demo app
A complete working integration in Python: BLE transport, protocol parsing, session lifecycle, and the realtime connection in roughly 600 lines. Written to be read — start in main.py and the whole flow is there top to bottom.
Package structure
Running it
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
export BRAINLIFE_API_KEY=bl_sk_your_key_here
python -m brainlife_demo.main --simulate
With a band in hand:
python -m brainlife_demo.main --scan
python -m brainlife_demo.main --address <ADDR>
Expected output:
INFO device Brainlife Focus+ fw=v0.4.0 battery=72%
INFO session ses_3f9c… started
focus focused 0.87 |########################## |
bands AF3 delta= 12.4 theta= 8.1 alpha= 15.7
device connected=True battery=72% POOR CONTACT: AF4
Requires Python 3.10 or newer, on macOS 11+, Windows 10+, or Linux with BlueZ 5.55+.
Integration FAQs
Why is my message quota exhausted so quickly?
Almost always one message per sample. At 128 Hz that is 128 messages per second per user. Batch 0.25–1 second into each message.
Classification looks wrong, but my signal looks clean
Check whether you are filtering before sending. Our engine expects raw ADC counts and applies its own filtering; a second chain in front of it skews results in ways that are very hard to trace.
Why was my session_id rejected?
Session ids are server-issued. Take the one returned by POST /v1/sessions and use it unchanged.
The user's signal went flat — are they relaxed?
Unknown without contact_quality from the device message. A detached electrode and a calm user produce the same trace.
My connection drops every few hours
Expected. Maximum connection lifetime is 8 hours by design. Fetch a new ticket and reconnect with backoff.
Should I retry after a close code?
Only for 1000, 4001, 4002, 4008, and 4009. The rest are configuration errors that will never succeed on retry.
Can I use my own analysis instead of yours?
Yes — the raw endpoint gives you back everything your devices produced. Many research partners do exactly this.
Support
Reach your Brain-Life technical contact for repository access, environment URLs, key rotation, quota changes, or device registration.
When reporting an integration problem, include your organization_id, the session_id, and the timestamp range. Do not include raw signal data or anything identifying an end user.