Demo app Support

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.

API v1 BLE protocol v2.0 Firmware v0.4.0 Draft

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 providesYou provide
Signal filtering and feature extractionThe application
Focus, relaxation, power bandsUser experience
AI models and version managementBusiness logic
Session storage and insightsYour 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.

Never put personal data in external_user_id

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.

You can start without hardware

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

ScopeGrants
ingest:writePush signal data, open and end sessions
realtime:readOpen a realtime connection
sessions:readRead your historical sessions
insights:readRead aggregated trends
devices:manageRegister and activate bands
raw:readDownload raw EEG/PPG
Connections close after 8 hours by design

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.

Never generate your own session_id

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.

RuleValueWhat happens
Maximum duration4 hoursSession auto-ends; open a new one
Idle timeout10 minutesSession auto-ends
Too short to analysesession.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
}
FieldMeaning
timestampTime of the first sample in the batch, epoch milliseconds
eegRaw ADC counts per channel, unfiltered
ppgRaw ADC counts from the photoplethysmography sensor
sample_rateHz — must match the device configuration

Channel names follow the international 10-20 convention, so existing EEG tooling reads them without translation.

Batch 0.25–1 second per message

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.

Send raw ADC counts — do not pre-filter

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
}
Handle the device message — it is not optional

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.

CodeMeaningRetryAction
1000Normal closeYesReconnect
1009Message exceeds size limitNoReduce batch size
4001Ticket invalid, used, or expiredYesFetch a new ticket
4002Authentication not completed in timeYesFetch a new ticket
4003Insufficient scopeNoContact Brain-Life
4004Device does not belong to youNoCheck device_id
4005Device revokedNoReactivate the band
4006API key revoked or expiredNoContact Brain-Life
4008Concurrent connection limit reachedYesBack off, then retry
4009No active sessionYesOpen 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

CharacteristicUUIDProperties
Sensor Control82e0d5f1-9462-4867-8bf3-70c9aea8e878READ, WRITE
EPC Control82e0d5f2-9462-4867-8bf3-70c9aea8e878READ, WRITE
EEG AF382e0d5f3-9462-4867-8bf3-70c9aea8e878NOTIFY
EEG AF482e0d5f4-9462-4867-8bf3-70c9aea8e878NOTIFY
PPG / fNIRS CH182e0d5f5-9462-4867-8bf3-70c9aea8e878NOTIFY
fNIRS CH282e0d5f6-9462-4867-8bf3-70c9aea8e878NOTIFY

Sensor Control sends configuration commands to the EPC chips and reads their configuration back. EPC Control starts and stops acquisition:

CommandValue
START ALL SENSORS0x01
STOP ALL SENSORS0x02

Full GATT service map

ServiceUUIDTypePurpose
Brainlife Streaming82e0d5f0-…CustomSensor control and data
Brainlife Control80896cd0-…CustomLED, vibration motor
Brainlife Factory810fb410-…CustomFactory firmware only — absent on customer units
Device Information0x180AStandardModel, serial, firmware, hardware, PnP ID
Battery0x180FStandardLevel and charge status
Generic Attribute0x1801StandardService Changed, database hash
Generic Access0x1800StandardDevice name, appearance
SMP8d53dc1d-…CustomPairing, bonding, encryption, secured OTA
Re-discover services after a firmware update

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.

CharacteristicUUIDExample value
Manufacturer Name0x2A29Brainlife
Model Number0x2A24Focus+
Serial Number0x2A25DUMMY_SN
Firmware Revision0x2A260.9.9+0
Hardware Revision0x2A27devkit, revB
PnP ID0x2A50Vendor 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 0flags
bytes 1–2power state (uint16)

Byte 0 — Status Flags. Indicates which fields are present.

BitMeaning
0Identifier present
1Battery level present
2Additional status present
3–7Reserved, all zero

Bytes 1–2 — Power State. This is where charge behaviour lives.

BitsFieldValues
0Battery present0 no · 1 yes
1–2Wired power connected0 no · 1 yes · 2 unknown
3–4Wireless power connected0 no · 1 yes · 2 unknown
5–6Charge state0 unknown · 1 charging · 2 discharging active · 3 discharging inactive
7–8Charge level0 unknown · 1 good · 2 low · 3 critical
9–11Charging type0 none · 1 constant current · 2 constant voltage · 3 trickle · 4 float
12–14Charging fault12 battery · 13 external source · 14 other
15Reserved
A falling percentage does not mean the band is running out

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.

byte 0header
bytes 1–8payload
byte 90x0A

The header identifies the channel, the tail is always 0x0A, and the payload is a little-endian signed integer of raw ADC counts.

HeaderChannel
0x24EEG AF4
0x25PPG channel 1
0x26EEG AF3
0x27fNIRS channel 1
0x28fNIRS channel 2

One streaming packet carries 21 data points — 210 bytes.

Never assume position implies channel

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.

MethodPathReturnsScope
POST/v1/sessionssession_id + ticketingest:write
POST/v1/sessions/{id}/endingest:write
POST/v1/realtime/ticketSingle-use ticketrealtime:read
GET/v1/users/{uid}/sessionsSession list, pagedsessions:read
GET/v1/users/{uid}/sessions/{id}One sessionsessions:read
GET/v1/users/{uid}/sessions/{id}/brainwavePer-second bandpowersessions:read
GET/v1/users/{uid}/sessions/{id}/timelineConfidence + classificationsessions:read
GET/v1/users/{uid}/sessions/{id}/rawRaw EEG/PPGraw:read
GET/v1/users/{uid}/insights/{kind}Cross-session trendsinsights:read
DELETE/v1/users/{uid}Erases that user everywheresessions:read
GET/v1/devicesYour registered bandsdevices:manage
POST/v1/devices/{id}/activateBind a band to an end userdevices: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.

LimitApplies per
Concurrent connectionsOrganization
Connections per end userOrganization
Messages per secondConnection
Ingest throughputOrganization

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.

EventFires when
session.completedSummary data is ready
session.failedSignal unusable or connection lost
device.activatedBand activated for the first time
device.offlineBand offline past the threshold
quota.thresholdYou reach 80% or 100% of quota
data.expiring7 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)>
Verify both the signature and the timestamp

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

DataRetainedReasoning
Raw EEG/PPG30 daysLargest by far; rarely read after a few days
Per-second power bands12 monthsCovers a year of trend analysis
Session summaries & insightsIndefiniteSmall, 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

brainlife_demo/ protocol.py GATT UUIDs and packet parsing. No BLE imports, so it unit-tests without hardware. ble.py Bleak transport, plus a band simulator. api.py REST session lifecycle and realtime WebSocket. main.py The integration itself. tests/ 22 protocol tests — no hardware, no network.

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

Request repository access →

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.

Brain-Life Platform API — Partner Integration Guide · Draft
Platform API v1 · BLE protocol v2.0 · Firmware v0.4.0