BluNotify Partner API — Guide

REST API for approved partners: notification lights and energy monitors. Version v1. Every route, parameter and field: the reference.

This is the full guide, covering raw commands, all channels, health monitoring, automation control, the Configuration API (device setup, org scoping, alert profiles), and the System API. If your account only has predefined-effect access on light2/light3/sound (with optional state read), the shorter quick-start guide may be all you need.

Overview

Base URL: https://api.blunotify.com. All requests and responses are JSON. Every endpoint is under /v1 and requires authentication. HTTPS is mandatory. A machine-readable description is available at /openapi.json, and the reference lists every route, parameter and field from it, with example requests and responses. This page is the guide: what the API is for, how scope, permissions and limits work, and what to watch for. Every response carries an X-Request-Id header; quote it when you contact BluNotify support (see Error codes).

Authentication

Authenticate with a bearer API key issued by BluNotify staff:

Authorization: Bearer bnp_3f8a2c91d0_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Treat the key like a password. Never put it in a URL, page, or client-side code. TLS protects it in transit; BluNotify never logs it.

Permissions

Each partner has explicit permission flags, grouped into the same categories BluNotify staff see when granting them. Your effective permissions are visible at GET /v1/account (as a flat map — the grouping below is purely for reading this reference). Under each flag's name below is its key in that map, which is also how a 403 names a permission you are missing (required_permission).

General device management

Product-independent: these gate the same routes for every device in your scope.

FlagGrants
State read
state_read
Read current light/sound/contact state via GET …/state. A BluEM meter has no channels, so its data is read with the BluEM permission Energy data read instead (BluEM API).
Health read
health_read
Read device connectivity/health via GET …/health.
Device provisioning (create)
device_provisioning
Register a device and its derived MQTT credential via POST /v1/devices/{id}. Independent of every other flag. A partner without Access ALL devices must name an organization inside its granted scope when it provisions — see Organization scoping.
Device removal (delete)
device_removal
Delete a device via DELETE /v1/devices/{id}. Applies to any device currently in your scope, not only ones you provisioned. Independent of every other flag. Removal is destructive-tier: it requires confirm equal to the device id and spends from the per-hour destructive-operation ceiling.
System read (settings / status / responses / file list)
system_read
System API — read cached settings, status/health, the command-response feed, the platform file list, the command registry, and file-transfer status. See System API.
System control (query, reboot, addfile, settings write…)
system_control
System API — send structured device verbs (query, reboot, addfile, settings writes to non-critical keys, …) and start a file transfer. Pushing a file and then polling it needs both this and system read.
System destructive (clear / factory reset / format / OTA)
system_destructive
System API — additionally allows clear settings, factory reset, filesystem format, OTA firmware, and writes to connection/identity keys. Every destructive call also requires a confirm field equal to the device id.
Platform file upload (files/, firmware/)
file_upload
System API — upload to the platform file server. Independent of every other flag (like provisioning/removal).
Platform file delete
file_delete
System API — delete from the platform file server. Independent of every other flag.
Config read (device config, org tree, sensor/profile listings)
config_read
Configuration API — read device configuration, your organization tree, sensor lists, and alert profiles. See Configuration API.
Config write (rename, org assign, sensor attach/detach/profile)
config_write
Configuration API — rename devices, move them between org nodes, and attach/detach sensors or set their alert profiles, on in-scope devices.

BluNotify — alerting devices

FlagGrants
Raw commands
raw_commands
Send validated raw payload strings (e.g. sldff0000) via POST …/commands.
Predefined commands
predefined_commands
Trigger named effects (e.g. solid_red) via POST …/effects.
Relaxed availability caps (raw commands only)
relaxed_caps
Add-on — lets raw commands (not predefined effects) exceed the normal availability caps (minimum flash period, maximum overlay duration/interval, etc.). The raw-command grammar is still always enforced. Independent of every other flag.
Automation control (suspend/resume sensor updates)
automation_control
Add-on — grants PUT …/automation (suspend/resume temperature-sensor updates). Independent of every other flag; must be granted explicitly.
Profile management (create/edit/delete partner-owned alert profiles)
profile_manage
Configuration API — create, update, and delete your own partner-owned alert-profile definitions (separate flag: editing a profile changes alerting everywhere it is assigned).
Alert signal management (create/edit/delete partner-owned alert signals)
signal_manage
Configuration API — create, update, and delete your own partner-owned alert signals: what a light shows for each alert state (separate flag: editing a signal changes every light showing it).

BluEM — energy monitors

FlagGrants
Energy data read (intervals, consumption, live reading)
energy_read
Read a BluEM meter's interval records and arrival feed via GET /v1/bluem/devices/{id}/intervals, a window's statistics via GET …/summary, its consumption via GET …/energy, and its live reading via POST …/sample and GET …/sample/{token}. The live reading asks the meter what it reads but changes nothing on it. Independent of every other flag, State read included, so a key can read meters and nothing else. See BluEM API.
Energy counter reset (cmd/resetenergy)
energy_reset
Zero a BluEM meter's lifetime energy counter via POST /v1/bluem/devices/{id}/resetenergy, and poll a reset's outcome. Destructive-tier: also requires a confirm equal to the device id and spends from the per-hour destructive-operation ceiling. Independent of every other flag — system destructive does not imply it. See BluEM API.

Device scope

FlagGrants
Access ALL devices
access_all_devices
Act on any device in the fleet, including future ones, rather than only devices inside your granted organization subtrees. Combined with removal or destructive flags this becomes fleet-wide reach — internal keys only.

Internal admin — raw MQTT (never granted to external partners)

FlagGrants
Raw MQTT — in-scope devices only (blunotify/<device-id>/…)
raw_mqtt_devices
System API — publish/read arbitrary MQTT under blunotify/{in-scope-id}/… only.
Raw MQTT — ANY topic (can reach $CONTROL / broker ACLs)
raw_mqtt_global
Publish/read any topic, bypassing device scope entirely and able to rewrite broker ACLs. Never granted to external partners.

Every flag above is independent — none is implied by any other. A request that lacks the required flag, targets a device outside your scope, or targets a channel you aren't permitted returns 403 (or 404 for an out-of-scope device).

When a request is refused because your key lacks a flag it needs, the 403 body names the flag in required_permission, by its GET /v1/account key: that is the permission to ask BluNotify staff for. Flags are checked before the rest of the request, so with the flag granted the same request can still be refused for something else, such as a channel outside your list or a bad parameter. A 403 that no permission would lift — that channel, a setting that cannot be written — names none, and neither does a 404.

403 {"error":"energy data access not permitted","code":"forbidden",
     "required_permission":"energy_read","request_id":"req_5f0c2a9e41b7d3c8a6e1f204"}

Devices & channels

You can only see and control devices in your scope. A device is in scope if it sits inside an organization subtree granted to you (see Organization scoping), or your account has the Access ALL devices scope flag — those are the only two sources of reach. Out-of-scope devices always return 404, indistinguishable from nonexistent ones. Each device exposes up to five channels:

ChannelMeaning
light1Primary/base light. Overwritten by temperature automation (see below).
light2Secondary overlay (higher priority; auto-wrapped with repeat timing).
light3Tertiary overlay (highest priority).
soundBuzzer.
contactRelay / contact output.

Your allowed channels are configured per partner, across your whole scope. GET /v1/devices lists your devices, which product each one is, and, on a BluNotify device, its permitted channels. A BluEM energy monitor has no channels.

Endpoints

GET /v1/account

Your partner name, permissions, allowed channels, and rate limits.

GET /v1/devices

Devices in your scope, in device-id order. Each: device_id, name, product (blunotify for a notification light, bluem for an energy monitor), and — only on a BluNotify device — channels and automation_enabled; and, only if you have health read, the connectivity fields status, status_changed_at and last_seen_at. Pass ?product=bluem (or blunotify) to list one product's devices; an unknown value is a 400, never an empty list.

{"devices":[
  {"device_id":"7C9EBD91235C","name":"Walk-in freezer","product":"bluem"},
  {"device_id":"F8B3B7229080","name":"Front desk","product":"blunotify",
   "channels":["light2","light3","sound"],"automation_enabled":true}]}
GET /v1/devices/{id}

A single device in your scope, with the same fields as in the list.

GET /v1/commands

Predefined commands available to you, with each parameter's min/max/default after your limits. This is the authoritative parameter reference for your account.

POST /v1/devices/{id}

Provision a new device under the firmware's derived-v1 credential contract, and place it in your scope. Requires the device-provisioning permission. The device ID is not yours to choose — it is the ESP32 base MAC as exactly 12 uppercase hexadecimal characters, no separators (^[0-9A-F]{12}$). That one string is the MQTT username, the HKDF salt, and the firmware device ID; anything else is a 400. It must not already be in use.

Every device holds a 16-character root secret in its one-time-programmable ESP32 eFuse, and its 32-character MQTT broker password is derived from that secret (HKDF-SHA256, salt = the device ID, label = <product>/mqtt/v1) rather than being an independent random string. The server stores only the derived MQTT password — never the root secret.

Body fieldMeaning
credential_formatRequired, and must be "derived-v1". Any other value (including omitting it) is a 400 that creates nothing.
credential_sourceWhat you are supplying; default generate. generate — the server mints the root secret and derives the password; the root secret comes back once as password. root_secret — you supply the device's existing 16-character eFuse secret and the server derives from it (how you re-enroll a device whose eFuse is already burned). mqtt_password — you supply the already-derived 32-character lowercase-hex broker password verbatim; no derivation happens. Only the field matching your choice may be sent; the others are rejected, not ignored.
productOptional; blunotify (default) or bluem. It selects the HKDF label the password is derived with, the MQTT topic root the device uses, and the broker ACL subtree its credential is granted. An unrecognised value is a 400 — never a silent default, because a device burned against one label can never produce a password derived from another. Always echoed back.
nameOptional metadata (device display name).
orgWhere to place the device — see Placement (org) below. Provisioning never creates org nodes.

Placement (org)

What org may be, and whether it is required, depends on your account's scope:

Your scopeorg rule
Access ALL devicesOptional, and must name an existing root organization. Omitted, the device is parked in the root Staging / Inventory node until someone places it. (Unchanged since the endpoint was written.)
Org-scoped (one or more granted subtrees, no Access ALL devices)Required, and must name a node inside one of your granted subtrees — so no device is ever created outside your own sight. Missing entirely is 400 validation_error.

For an org-scoped partner, a name that names no node at all and a name that names a real node outside your granted subtrees get the identical answer — 400 "unknown organization '<org>'" — so the response never confirms or denies the existence of an organization you cannot see. A name that matches more than one node inside your own scope is refused instead as 400 "organization '<org>' is ambiguous: N organizations in your scope share that name" — that answer only describes your own granted forest, already visible via GET /v1/org-tree.

# virgin device: the server mints the root secret and derives the password
curl -X POST https://api.blunotify.com/v1/devices/7C9EBD911E74 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"credential_format":"derived-v1","name":"Front Desk Light"}'

201 Location: /v1/devices/7C9EBD911E74
{"device_id":"7C9EBD911E74","credential_format":"derived-v1",
 "product":"blunotify","password":"Xq7mK2vD9tRb4Zs1","name":"Front Desk Light"}

# re-enrollment: the eFuse is already burned, so derive from the SAME secret
curl -X POST https://api.blunotify.com/v1/devices/7C9EBD911E74 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"credential_format":"derived-v1","credential_source":"root_secret",
       "root_secret":"Xq7mK2vD9tRb4Zs1"}'

201 {"device_id":"7C9EBD911E74","credential_format":"derived-v1","product":"blunotify"}
The password field is the 16-character root secret, not the MQTT password — the two must never be interchanged — and it is present only when credential_source was generate. It is shown exactly once and is not stored server-side in any form, so no one can recover it for you: write it to the device's eFuse and keep your own record of it. A credential you supplied is never echoed back.
If the root secret is lost, provisioning again does not help. The eFuse is one-time programmable, so a device already burned can only ever produce the password derived from the secret it holds; minting a fresh one just registers a password that device can never present. Re-registering a device you still hold the secret (or the derived broker password) for is credential_source root_secret / mqtt_password. With neither, the hardware cannot be re-enrolled — contact BluNotify before returning it. Provisioning an ID that already exists returns 409 and changes nothing: no rotation, no new secret, no metadata update.
DELETE /v1/devices/{id}

Remove a device: cuts its broker credential, then deletes its device record. Requires the device-removal permission. Applies to any device currently in your scope — not only ones you provisioned yourself (e.g. a demo device staff granted into your scope can also be removed if you hold this permission).

Confirmation is required. Pass confirm equal to the device id, either as a query parameter (shown below) or in a JSON body. Without it — or with a value naming a different device — the request is refused with 409 confirmation_required and nothing is deleted. Removal also spends a slot from the same per-hour destructive-operation ceiling as factory_reset, so a run of deletions is rate limited with 429.

curl -X DELETE "https://api.blunotify.com/v1/devices/front-desk-01?confirm=front-desk-01" \
  -H "Authorization: Bearer $KEY"

204 (no body)
This is the most irreversible call in the API: the broker credential and the device record both go, and the record takes its automation suspensions and sensor attachments with it. There is no undo — re-registering the device afterwards requires its root secret or derived broker password (see the provisioning note above).
POST /v1/devices/{id}/commands

Send a raw command. Requires the raw-command flag and channel access. Availability caps (minimum flash period, maximum overlay duration/interval, etc.) apply unless your account also has the relaxed-caps permission — the raw-command grammar itself is always enforced either way.

curl -X POST https://api.blunotify.com/v1/devices/DEV123/commands \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"channel":"light1","payload":"sldff0000"}'

202 {"channel":"light1","payload":"sldff0000"}
POST /v1/devices/{id}/channels/{channel}/effects

Trigger a predefined command with optional parameters. Requires the predefined flag, channel access, and that the command is granted to you.

curl -X POST https://api.blunotify.com/v1/devices/DEV123/channels/light2/effects \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"command":"flash_white","params":{"period_s":0.25,"duration_s":3,"interval_s":60}}'

202 {"command":"flash_white","channel":"light2","payload":"003000000060tglffffff000000025050"}

On light2/light3, effects are automatically wrapped as repeating overlays; pass duration_s (how long each occurrence lasts) and interval_s (how often it repeats). The payload field (the encoded wire-format string) is only included in the response if you also have raw-command access — otherwise the response omits it and returns just command and channel. Both routes answer 202 Accepted: the command was handed to the broker, which is all the API waits for.

GET /v1/devices/{id}/state

Latest command per permitted channel, with a decoded label. Requires state read.

{"device_id":"DEV123","state":{"light1":{"payload":"sld00ff00","label":"Green (Solid)","updated_at":"2026-07-04T12:00:00+00:00"}}}

The raw payload field is only present if you also have raw-command access; without it, each channel returns just label and updated_at.

GET /v1/devices/{id}/health

The connectivity fields and recent health telemetry. Requires health read. Query params: hours (default 24, max 720) and limit (max 500).

{"device_id":"DEV123","status":"connected","status_changed_at":"2026-09-27T14:02:11+00:00",
 "last_seen_at":"2026-09-27T15:40:03+00:00",
 "telemetry":[{"created_at":"…","topic":"…","payload":"…"}]}

Connectivity fields

FieldMeaning
statusconnected or disconnected. A device is connected from the moment it sends anything, and disconnected the moment the broker reports its connection lost (or after about 25 minutes of silence, if that report never arrives). This is the same status the BluNotify console shows.
status_changed_atWhen status last changed (ISO 8601, UTC), or null if not known.
last_seen_atWhen the device last sent anything itself (ISO 8601, UTC), or null if BluNotify has no record of it sending anything. Traffic BluNotify sends to the device never counts.
PUT /v1/devices/{id}/automation

Enable/disable temperature-sensor automation for a device. Requires the automation-control permission (an independent add-on).

curl -X PUT https://api.blunotify.com/v1/devices/DEV123/automation \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"enabled":false}'

{"device_id":"DEV123","automation_enabled":false}

Command parameters

Parameterized effects accept fields such as period_s, duty_ratio, brightness_pct, duration_s, interval_s, and volume. Every value is validated against a configurable min/max; an out-of-range value is rejected (not clamped) with 400. Your exact allowed ranges are in GET /v1/commands. To protect device availability, the API also caps effect duration, minimum flash period, and repeat interval.

Requests and responses

StatusMeaning
200Here is what you asked for.
201Created (a device, a profile, a sensor attachment, an uploaded file). Location names it.
202Accepted: a command was handed to the broker, or a job was started. When there is something to poll, Location names it.
204Done, with nothing to return (a rename, a move, an update or a removal). There is no body.

Error codes

HTTPcodeMeaning
400validation_errorBad channel, command, or parameter value.
401unauthorizedMissing/invalid/revoked key.
415unsupported_media_typeA request body that is not sent as application/json.
403forbiddenAuthenticated but not permitted for this flag/channel/command.
403node_out_of_scopeThe target org node of a device move is outside your granted org scope.
403limit_reachedProfile cap reached (50 partner-owned profiles).
404not_foundDevice outside your scope (or doesn't exist — indistinguishable by design). Also returned for a profile that isn't global or yours.
404unknown_routeNo route matches the URL: check your base URL and the path. Says nothing about any device.
405method_not_allowedThe path exists but not with that HTTP method.
409conflictThe device ID being provisioned is already in use (the existing device is left untouched), or a platform file of that name already exists (uploads never overwrite).
409confirmation_requiredA destructive call — a destructive System-API verb, a connection/identity settings write, or DELETE /v1/devices/{id} — was missing or mismatched its confirm field (must equal the device id).
409would_lose_accessAn org move that would remove your own access to the device.
409already_attachedThe sensor is already attached to that device.
409name_takenA profile with that name already exists in your scope.
409in_useThe profile is still assigned to one or more sensors and cannot be deleted.
409encryption_unavailableA secret setting cannot be encrypted for this device, so nothing was sent (see System API).
409device_offlineA BluEM live reading was asked of a meter that is not connected; nothing was sent.
409idempotency_in_progressA request with the same Idempotency-Key is still running. Retry after Retry-After (see Retrying safely).
422idempotency_key_reusedThe Idempotency-Key was already used with a different request. Use a new key for a new request.
429rate_limitedToo many requests. Honor the Retry-After header.
503publish_failedCommand was valid but could not be delivered to the device.

Errors are JSON: {"error":"…","code":"…","request_id":"…"}. code is the stable word to branch on; error is a sentence for people and may be reworded. New codes may be added, so handle one you do not know by its HTTP status. A 403 can also carry required_permission (see Permissions).

Request IDs. Every response carries an X-Request-Id header, and every error body the same value as request_id. Log it with your own error, and quote it when you contact BluNotify support: it finds your call in our records.

Rate limits

Limits apply per partner (requests/minute) and per device (commands/minute), plus a global per-device ceiling shared across partners. Configuration-API mutations (rename, org move, sensor changes, profile writes) share a separate per-partner throttle (default 30/minute). Destructive operations — destructive System-API verbs, connection/identity settings writes, and DELETE /v1/devices/{id} — additionally share one per-partner per-hour ceiling. Exceeding a limit returns 429 with a Retry-After header (seconds). Build in backoff and avoid tight retry loops.

Every response to a request with a valid key describes your per-partner request limit, so you can slow down before you reach it:

HeaderMeaning
RateLimit-LimitRequests allowed per minute.
RateLimit-RemainingRequests the current minute still allows. 0 on the 429 this limit sends.
RateLimit-ResetSeconds until the oldest request counted in the current minute leaves it, when RateLimit-Remaining goes up again.

They describe the per-partner request limit only. The per-device and per-operation limits above answer with a 429 and Retry-After as before.

Retrying safely: Idempotency-Key

A request that times out may still have reached the device. To retry any POST without doing its work twice, send an Idempotency-Key header: a value you generate for that one request, such as a UUID, and send again unchanged on every retry of it.

curl -X POST https://api.blunotify.com/v1/bluem/devices/EM123/report \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: 5b0f7e2c-3a41-4d6e-9f3e-8c1d2b7a9e10"
SituationWhat you get
First request with a keyIt runs as usual.
The same request again, same key, within 24 hoursThe first response again, status and body, without the work being done twice. It carries Idempotent-Replayed: true. A 202 still names what to poll in Location.
The same key with a different request422 idempotency_key_reused. Use a new key for each new request.
A retry while the first is still running409 idempotency_in_progress with Retry-After. Wait and retry.
The first request failedOnly a successful (2xx) response is kept. After a failure the same key runs the request again.

A key belongs to your account and the route it was sent to: the same value on another route, or from another partner, is a different key. A key is kept for 24 hours; after that it is new again. A replay is the stored answer as it was: it is not checked again against your permissions or scope as they are now. A replay still counts against your request rate limit, but not against the device's command limits, because nothing is sent. The header is not taken by the platform file upload (POST /v1/system/files), which never overwrites a file, so a repeat is already refused with 409. For POST /v1/devices/{id} (provisioning), a replay returns the generated password again: the one way to recover it if the first response was lost.

POST /v1/devices/{id}/files also recognises a key it has seen for as long as the transfer exists, answering 200 with the transfer's current state once the 24 hours have passed.

Automation interaction & conflicts

light1 is special. BluNotify's temperature-sensor automation publishes light1 whenever the light's alert state changes, and again on an hourly pass; what it shows for each state is the light's alert signal. A command you send to light1 will be overwritten unless you disable automation for that device with PUT …/automation (requires the automation-control permission). Overlay channels light2/light3, sound, and contact are never touched by automation and are the recommended channels for notifications.
Conflicts (temporary policy). If several partners share a device, the API is first-come, first-served: a later valid command overrides an active one. This is intentional for v1 and may change in a future version.

Configuration API — device setup, org scoping, alert profiles & signals

The Configuration API lets a partner manage its fleet's setup the way BluNotify's own console does: rename devices, place them in an organization tree, attach temperature sensors (TDLs), and control which alert profile (rule document) each sensor is evaluated with. It is gated by three independent flags — config read, config write, and profile manage — and every call is audited. Config mutations share a separate per-partner rate limit (default 30/minute).

Organization scoping

Your reach is defined by exactly two sources, combined: BluNotify staff can grant your account one or more organization subtrees (any device attached to a node inside a granted subtree is in scope for you), and/or your account can hold the Access ALL devices scope flag (every device in the fleet, including future ones). There is no other way for a device to enter your scope — devices are no longer assigned to a partner individually. GET /v1/org-tree returns your granted subtrees as a forest (each granted root's parent_id is nulled); with the access-all-devices scope flag it returns the whole tree. No grants means an empty forest, not an error.

GET /v1/org-tree

Your visible org nodes: {"nodes":[{"id","parent_id","name"}]}. Requires config read.

GET /v1/devices/{id}/config

A device's configuration: device_id, name, org_node, its attached sensors (each with tdl_id, label, profile), automation_enabled, and alert_signal_id (the light's alert signal; null = the default). Requires config read.

PATCH /v1/devices/{id}/config

Rename a device. Body: {"name"} (1–120 characters). Requires config write.

PUT /v1/devices/{id}/org

Move a device to an org node (or detach with null). Body: {"org_node_id"}. Requires config write. The target node must be inside your own granted scope (403 node_out_of_scope otherwise — you can never expand your reach). For an org-scoped partner (no Access ALL devices), org_node_id: null is always 409 would_lose_access: since a granted subtree is your only reach to the device, detaching it would strand your own access — only a partner with Access ALL devices may park a device back in Staging / Inventory this way.

POST /v1/devices/{id}/sensors

Attach a sensor. Body: {"tdl_id", "profile_id"?} — tdl_id is 1–64 chars (letters, digits, _, -, ., :); profile_id must be a global or your own partner-owned profile (null = Default). 409 already_attached if the sensor is already on that device. Requires config write.

PUT /v1/devices/{id}/sensors/{tdl_id}

Change an attached sensor's alert profile. Body: {"profile_id"} (null clears to Default). Requires config write.

DELETE /v1/devices/{id}/sensors/{tdl_id}

Detach a sensor from a device. Requires config write.

Alert profiles

An alert profile is an ordered rule document — first matching rule wins — that maps a sensor's recent readings to an alert level. Each rule is a set of conditions ({"metric", "op", "value"}) that must all hold. Metrics include reading_count, newest_age_secs, latest_breach, consecutive_breaches, breaches_in_window, latest_temp_c, latest_humidity, and the sensor's min_temp_c/max_temp_c thresholds; operators are ==, !=, <, <=, >, >=, is_null, not_null. Every document is validated server-side (≤20 rules, ≤8 conditions per rule) and rejected with 400 validation_error if malformed.

GET /v1/profiles

Profiles visible to you: global profiles plus your own partner-owned ones — each with id, name, scope, version, description, rules. Requires config read.

POST /v1/profiles

Create a partner-owned profile. Body: {"name", "rules", "default"?}. Requires profile manage. Capped at 50 profiles per partner (403 limit_reached); a duplicate name is 409 name_taken.

curl -X POST https://api.blunotify.com/v1/profiles \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Strict freezer",
       "rules":[{"all":[{"metric":"reading_count","op":"<","value":3}],"level":"INSUFFICENT_DATA"},
                {"all":[{"metric":"consecutive_breaches","op":">=","value":2}],"level":"WARNING"}],
       "default":"CLEAR"}'

201 Location: /v1/profiles/17
{"id":17}
PUT /v1/profiles/{id}

Update one of your own profiles (name and/or rules). Global, system, and other partners' profiles return 404. Each update bumps the profile's version and records a revision. Requires profile manage. Note: the change takes effect everywhere the profile is assigned, on the next alert-evaluation cycle (~2 minutes).

DELETE /v1/profiles/{id}

Delete one of your own profiles. Refused with 409 in_use (including the sensor count) while any sensor still references it — reassign those sensors first. Requires profile manage.

Alert signals

An alert signal decides what a light shows (light1) for each alert state. It has four entries: CLEAR, ADVISORY, WARNING, and NO_DATA, which covers the four states where the temperature cannot be judged (NO_SENSORS, INSUFFICENT_DATA, STALE_DATA, UNKNOWN). A light with no signal of its own shows the default signal: solid green, solid amber, solid red, and pulsing blue. A change reaches a light at its next state change, or within the hour.

Each entry is {"light": {"type", …fields}}. Every field is required; a value outside its range is refused (400 validation_error, naming the entry and field), never rounded. Colours are {"r","g","b"} whole numbers 0–255. Periods are 0.1–9.999 s, animation times 0.1–99.999 s, width 1–12, duty ratios 0.01–0.99, and repeat_s a whole number of seconds 0–999999 (0 = play once; otherwise it must outlast the animation).

typeFields
Solidcolor
Pulsecolor, period_s
PulseThenSolidpulse_time_s, pulse_color, pulse_period_s, solid_color, repeat_s
Chasecolor, period_s, width
ChaseThenSolidchase_time_s, chase_color, chase_period_s, chase_width, solid_color, repeat_s
Togglecolor1, color2, period_s, duty_ratio
ToggleThenSolidtoggle_time_s, toggle_color1, toggle_color2, toggle_period_s, toggle_duty_ratio, solid_color, repeat_s
BluChaseperiod_s, brightness_per (0–99)
GET /v1/alert-signals

Signals visible to you: the global ones (including the default, marked is_default) plus your own — each with id, name, scope, is_default, version, description, entries. Requires config read.

POST /v1/alert-signals

Create a partner-owned signal. Body: {"name", "entries", "description"?}. Requires alert signal manage. Capped at 50 signals per partner (403 limit_reached); a duplicate name is 409 name_taken.

curl -X POST https://api.blunotify.com/v1/alert-signals \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Freezer, loud warning",
       "entries":{
         "CLEAR":   {"light":{"type":"Solid","color":{"r":0,"g":255,"b":0}}},
         "ADVISORY":{"light":{"type":"Solid","color":{"r":255,"g":255,"b":0}}},
         "WARNING": {"light":{"type":"PulseThenSolid","pulse_time_s":10,
                     "pulse_color":{"r":255,"g":0,"b":0},"pulse_period_s":0.5,
                     "solid_color":{"r":255,"g":0,"b":0},"repeat_s":600}},
         "NO_DATA": {"light":{"type":"Pulse","color":{"r":0,"g":0,"b":255},"period_s":2}}}}'

201 Location: /v1/alert-signals/12
{"id":12}
PUT /v1/alert-signals/{id}

Update one of your own signals: any of name, description, entries. Global signals (the default among them) and other owners' return 404. Returns the new version. Requires alert signal manage.

DELETE /v1/alert-signals/{id}

Delete one of your own signals. Allowed while lights show it: they go back to the default signal. Requires alert signal manage.

PUT /v1/devices/{id}/alert-signal

Choose a light's signal. Body: {"alert_signal_id"} — a global signal or one of your own; null (or the default's id) means the default. Any other id is 404. Requires config write.

System API — device MQTT interface

The System API is the partner-facing equivalent of BluNotify's admin Device Console: it reaches a device's system interface (Device API Reference Rev 1.2) — NVS settings, diagnostics, lifecycle (reboot/reset/format), platform-file distribution, and OTA firmware. It is gated by its own independent permission flags and, like everything else, fully audited. Everything here is additive under /v1; nothing about the notification endpoints above changes.

Tiers & confirmation

Connection-critical settings keys — mqttUser, mqttPass, devId, topicRoot, host, tlsMode, mqttPort, caCertFile — are classified destructive: still writable, but a bad value permanently disconnects the device, so they need the destructive flag + confirm. All other keys need only system control.

Secret settings values are encrypted for the device

Cellular devices reach the broker in plaintext MQTT over a private carrier VPN, so a settings value that is a credential is never published in the clear. Write a key whose name contains pass, pwd, key, token, secret or psk — in practice wifiPwd, mqttPass, gprsPass — and BluNotify wraps your value in an authenticated envelope only that one device can open, on every transport:

PUT /v1/devices/{id}/system/settings/wifiPwd   {"value": "hunter2"}

202 {"key":"wifiPwd",
     "payload":"wifiPwd=enc1:0mjvF7D3K/Spejqa+tGghX2/Kg3oukqsS5nqCQgWtsuKnO8=", …}

You send the plaintext; the payload you get back is the ciphertext, and so is the device's acknowledgement on the response feed — match acks against the returned payload, never against the value you sent. Nothing else changes: same topic, same QoS, same >ok/>failed suffixes, and the setting still applies on the device's next reboot. Ordinary keys stay cleartext so the traffic remains readable. The raw MQTT channel is treated identically — publishing wifiPwd=… to a device's sys/exe by hand is encrypted the same way.

409 encryption_unavailable — the value could not be encrypted for this device, so nothing was published. There is deliberately no cleartext fallback. Two causes, and the message says which: BluNotify does not hold that device's encryption key (an administrator supplies it from the unit's provisioning record), or the device's firmware predates encrypted settings and would store the literal enc1:… string as the password. Non-secret keys are unaffected either way.

One limitation worth stating plainly: an envelope is bound to its key name and its device, and cannot be altered — but it is not replay-proof. Anyone who captured an earlier envelope for the same key on the same device could replay it to restore that earlier value. They cannot choose the value, and cannot aim it at a different key or device.

Fire-and-forget & the response feed

Devices are asynchronous: a command is a one-shot publish, not a request/response. So command, settings-write, and raw-publish endpoints return 202 Accepted with the exact topic, payload, and sent_at that were sent. Poll GET /v1/devices/{id}/system/responses and match the acknowledgement: the device replies with your payload followed by >ok, >failed, >applied, or >unknown (e.g. you sent query>, the ack is query>ok). Rows come oldest first. Pass each answer's next_cursor back as cursor for the rows after the last one you have; has_more says whether more are waiting now, and next_cursor is always set, so a poll loop just keeps the latest one. Without a cursor you get the last ten minutes, or the rows at or after since (ISO 8601).

since with server_now is the older way to page and still works, but prefer the cursor: when more rows arrive between two polls than one page holds, since = server_now skips the ones that did not fit, and its + offset must be percent-encoded. A cursor is URL-safe and never skips.

Endpoints

GET /v1/system/commands

The command registry: every verb, its tier, its parameters, whether it needs confirm, and whether your account currently holds that tier. Also lists the destructive settings keys. Requires system read.

GET /v1/devices/{id}/system/settings

Cached NVS settings (from the device's last connect/query), plus fw_ver and the device file_list. Values the firmware masks appear as *****. Requires system read.

GET /v1/devices/{id}/system/status

The connectivity fields, last connection string, on-demand status snapshots, and the periodic health heartbeat. Requires system read.

GET /v1/devices/{id}/system/responses

Recent command acknowledgements and device log lines, oldest first. Query params: cursor (a previous answer's next_cursor), limit (default 200, max 1000), and the older since. The answer carries rows, has_more, next_cursor and server_now. Requires system read.

GET /v1/system/files

The platform files devices download (files/ + firmware/), each with size and modified time. Requires system read.

POST /v1/devices/{id}/system/commands

Run a structured verb. Body: {"command", "params"?, "confirm"?}. Control verbs need system control; destructive verbs need system destructive + confirm. Returns 202.

curl -X POST https://api.blunotify.com/v1/devices/DEV123/system/commands \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"command":"query"}'

202 {"command":"query","topic":"blunotify/DEV123/sys/exe","payload":"query>","sent_at":"2026-07-07T12:00:00+00:00"}

# destructive verbs require confirm == device id
curl -X POST https://api.blunotify.com/v1/devices/DEV123/system/commands \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"command":"factory_reset","confirm":"DEV123"}'

File-backed verbs reference a file already on the platform file server: addfile / setcert take a name in files/ (the SHA-256 is computed for you); ota_update takes a .bin in firmware/. rmfile removes a file on the device. Call GET /v1/system/commands for the full parameter list. Factory reset erases the MQTT credentials — the device must be re-provisioned (pairs with the device-provisioning permission) before it reconnects. OTA is inert unless the image carries a valid RSA-3072 signature. Uploaded files mirror to storage within ~1 minute before a device can fetch them.

PUT /v1/devices/{id}/system/settings/{key}

Write one NVS key. Body: {"value", "confirm"?}. Ordinary keys need system control; the connection/identity keys above need system destructive + confirm. Returns 202.

POST /v1/system/files

Upload a platform file (multipart: file + dir=files|firmware). Requires file upload. Extension allowlist; firmware/ accepts .bin only; at most 32 MB (larger is 413). Uploads never overwrite — a name clash is 409. Returns 201 with the stored name, size, and SHA-256.

An .mp3 uploaded into files/ is checked against what a BluNotify device can play. One that holds no MP3 (MPEG Layer III) audio — a WAV with an .mp3 name, an MP2 file — is refused with 400 validation_error and the reason. Any other comes back with an audio object: playable, ideal, a one-line summary.text, and issues[] of {code, severity, message} saying what would make it better. Warnings never block the upload. Devices play best from a mono MP3 at 16 kHz and 48 kbps constant bitrate, with no ID3 tags or cover art, mastered loud (peaks around −1.5 dB — a device's volume setting can only turn a file down). All of a device's sound files share about 9.75 MB of storage.

DELETE /v1/system/files

Delete a platform file: ?dir=&name=. Requires file delete. Protected names are refused.

Pushing a file to a device — the durable transfer job

Sending a file with the addfile verb above is fire-and-forget, so you would have to infer arrival by re-reading the device's file list. These endpoints do it properly: the push returns a transfer — a durable job with a correlation token — and you poll that one object until it settles. Nothing else needs to be watched, and no command has to be re-sent to find out what happened.

POST /v1/devices/{id}/files

Body: {"name", "dest"?}. name is a file already in the platform files/ directory (no bytes travel through this call — upload first with POST /v1/system/files); dest is the device-side name and defaults to name. The SHA-256 is always computed server-side and always sent — there is no option to omit it, because the firmware requires it. Requires system control; polling requires system read.

Send an optional Idempotency-Key header to make retries safe (see Retrying safely): a repeat within 24 hours gets the first 202 again, and after that this route still recognises the key, answering 200 with the original transfer as it stands; nothing is downloaded a second time. A new push is 202, with the transfer to poll in its Location header. The response body is the transfer document below, plus device_online on this call only.

curl -X POST https://api.blunotify.com/v1/devices/DEV123/files \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: boot-mp3-attempt-1" \
  -d '{"name":"boot.mp3","dest":"boot.mp3"}'
GET /v1/devices/{id}/files/transfers/{transfer_id}

The same document, resolved as of the read. This is the one thing to poll.

GET /v1/devices/{id}/files/transfers

Your transfers for a device, newest first: ?state=&since=&limit=. The reconciliation read for a client that was down while transfers were in flight.

The transfer document

{
  "transfer_id": "ft_48abbe9daf9e0e9bae04500654c97c7a",
  "device_id":   "DEV123",
  "state":       "sent",          // queued | sent | succeeded | failed | timed_out
  "terminal":    false,           // poll until this is true
  "src":         "boot.mp3",
  "dest":        "boot.mp3",
  "sha256":      "64d0f6…",
  "corr_mode":   "envelope",      // or "url_token" — informational only
  "attempts":    1,
  "detail":      null,            // the device's reply / failure reason once terminal
  "poll_after_s": 15.0,           // seconds to wait before the next poll; null once terminal
  "note":        "…",             // present while "sent" — explains the offline window
  "timeline": { "created_at": "…", "sent_at": "…",
                "ack_deadline": "…", "settled_at": null }
}
Terminal stateMeaning
succeededThe device acknowledged the file.
failedThe device explicitly reported a failure — detail says why. Do not retry blindly.
timed_outNo reply inside ack_deadline. See the caveat below — this does not always mean the file is absent.
Poll on poll_after_s, not on a timer of your own. The server knows a file push is slow and an argument-less command is not, and it sets the cadence accordingly. Treat terminal as the only stop condition.
Do not impose a short client timeout. The ack window is roughly fifteen minutes. addfile makes the device drop its MQTT session for the whole download, and the firmware retries several times before answering — so a healthy transfer can legitimately be silent for minutes. Honour timeline.ack_deadline, or use a transfer-specific timeout well above it. A client timeout shorter than the server's deadline reports failure on transfers that are still working.
device_online: false mid-transfer is normal, not a fault: that is the download window, and it is what note is telling you. Do not abort on it.
A timed_out transfer may still have delivered the file. The device publishes its acknowledgement immediately after the download — exactly while it is re-establishing the MQTT session it dropped in order to download — so under a slow reconnect the ack can be lost although the file arrived. On timed_out only (never on failed), do one GET /v1/devices/{id}/system/settings and check file_list before declaring the run failed.
BluNotify devices accept .mp3 only, and it must be MP3 (MPEG Layer III) audio. Any other extension is 400 validation_error and nothing is published. It is final — retrying cannot help. A name that is not in the platform files/ directory fails the same way.

Raw MQTT channel

Raw MQTT (any topic) is internal-admin-only and is never granted to external partners. It bypasses device scope entirely and can reach broker-control topics ($CONTROL/dynamic-security, i.e. ACL changes). Its effective power is bounded by the broker ACL on the partner-API MQTT credential.

Two scope levels: Raw MQTT — in-scope devices only permits topics/filters under blunotify/{in-scope-id}/… only; Raw MQTT — ANY topic permits any syntactically valid topic.

POST /v1/mqtt/publish

Publish an arbitrary payload. Body: {"topic", "payload", "retain"?, "qos"?} (retain default false; qos default 2 for sys/*, else 1; publish topics may not contain wildcards). Returns 202. A raw publish is sent as written: it is not checked against the settings catalog, so none of the settings route's range checks or refusals apply to it.

GET /v1/mqtt/messages

Read recorded broker traffic by MQTT topic filter (+/# wildcards): ?topic=&limit=. The first call returns the newest rows, newest first; pass its next_cursor back as cursor to follow what arrives after them, oldest first, with has_more as on the response feed. The older since still filters the first call. Under the in-scope-devices-only scope the filter must sit under one in-scope device's subtree (a device-spanning filter like blunotify/+/… is rejected). History depth is bounded by BluNotify's message-retention window.

Grant-combination warnings. Access all devices + device removal = can delete any device (one confirmed call at a time, under the destructive ceiling); + system destructive = fleet-wide destructive reach. These combinations, and either raw-MQTT flag, are intended for internal keys only. Per-device and per-partner rate limits (including a per-hour destructive-op ceiling) still apply regardless.

BluEM API — energy monitor devices

New to BluEM? The BluEM quick start takes you from a key to your meter's data in six steps. This section is the full description.

BluEM is a second product on the platform: a single-phase energy monitor (a PZEM-004T power meter behind an ESP32). Its product routes live under their own prefix, /v1/bluem, rather than the paths above. The product-agnostic parts of /v1/devices/… — the device list and GET /v1/devices/{id}, GET …/state, GET …/health, the System API and file transfers — serve an in-scope BluEM device exactly as they serve a BluNotify one (the device list says which is which: "product":"bluem", and GET /v1/devices?product=bluem lists meters only); only BluNotify's product routes (raw commands, effects, automation, sensors) refuse it, and a BluNotify device is likewise refused everywhere under /v1/bluem/… — in both cases with the same 404 an out-of-scope device gets, so neither product surface reveals a foreign device's product by answering differently. Everything above this section (auth, rate limits, error shape, the System API's fire-and-forget/response-feed conventions) applies here unchanged.

What a meter reports

A BluEM device does not stream values; it keeps interval records — average/min/max per quantity (volts, amps, watts, hertz, power factor) over a reporting period (15 minutes by default), plus its lifetime energy counter — and resends each until the server has stored it. GET …/intervals reads that history record by record; GET …/summary adds up a window of it for you; GET …/energy reads the same table pre-bucketed into hourly or daily consumption. For what the meter reads right now, ask for a live reading.

Integrator notes

Read these before you write your sync. Each one is something the data does that a first integration usually gets wrong.

WhatWhat to do
Intervals are not always 15 minutes.The first interval after a restart, a firmware update or a clock sync is shorter or longer, and an on-demand report closes one early. Use each record's start_at and duration_s. Never assume 900 seconds.
A record belongs where its middle is.Every window and bucket on these routes — start/end on …/intervals and …/summary, each hour or day of …/energy — holds the records whose midpoint (start_at + duration_s/2) falls inside it. A meter's start time can be a second either side of the quarter-hour, so the interval that begins at midnight may say 23:59:59; it is still the new day's, and a list for that day includes it. To add records up yourself and match …/energy, place them the same way.
Data can arrive late.After a network outage a meter sends what it stored, which can be weeks old. A pull by start/end over "the last hour" never sees a record that arrives today for last Tuesday. Use the arrival feed, which returns records in the order the server stored them. Hourly and daily totals from GET …/energy for past periods also go up when late records arrive.
Some records have no start time.A meter that restarted and sent a record before it learned the time sends it with "start_at": null. The arrival feed includes these records. A pull by start/end leaves them out unless you pass unknown_time=true, which returns only them.
Averages have to be weighted.Because intervals differ in length, the average over several records is each avg times its duration_s, summed, divided by the summed duration_s. The lowest and highest values are the records' own min and max, not the lowest and highest averages. GET …/summary does this for you.
You will not see duplicates from the meter.A meter may resend a batch after a restart. The server stores each record once. A record is identified by sid and seq together; use that pair as your own key.
POST …/report is not a refresh.Every call closes the interval in progress and stores a short record. Once in a while is fine. On a timer it chops the history into fragments and uses cellular data. To see what the meter reads now, use the live reading, which stores nothing.
Daily totals are UTC days unless you say otherwise.Pass tz to GET …/energy to get your own local days and hours.
Four settings cannot be written.emAck, pmBaud, pmSlave and emCtTurns are fixed by the meter hardware and its wiring. A write through the settings route is a 403. Ask BluNotify if one needs to change.
GET /v1/bluem/devices/{id}/intervals

Requires energy data read. Query params: start/end (ISO-8601 or epoch seconds; end defaults to now, start to 24h before it, and the span is capped at a year), limit (default 200, max 1000), cursor (a previous page's next_cursor, verbatim), and unknown_time. A record is in the window when its midpoint is.

{"device_id":"EM123","unknown_time":false,
 "window":{"start":"2026-09-24T12:00:00+00:00","end":"2026-09-25T12:00:00+00:00"},
 "records":[
   {"seq":118,"sid":"1a2b3c4d","start_at":"2026-09-25T11:45:00+00:00",
    "time_quality":"device","duration_s":900,
    "reads":{"n":900,"err":0},
    "voltage_v":{"avg":229.83,"min":228.1,"max":231.4},
    "current_a":{"avg":1.203,"min":0.9,"max":1.6},
    "power_w":{"avg":276.1,"min":205.0,"max":370.0},
    "frequency_hz":{"avg":50.01,"min":49.9,"max":50.1},
    "power_factor":{"avg":0.947,"min":0.9,"max":0.99},
    "energy_wh":{"first":100020.0,"last":100089.0},
    "ct_turns":1,
    "alarm_reads":0,
    "flags":{"partial":false,"energy_reset":false,"meter_reconfigured":false},
    "quarantined":false,"quarantine_reason":null,
    "received_at":"2026-09-25T12:00:01.532+00:00"}],
 "has_more":false,
 "next_cursor":null}

received_at is when the server took the record off the network. For a record sent on time it is a second or two after the interval ended; for one a meter stored through an outage it is much later than start_at.

Records come back oldest first, ordered by (start, stream id, sequence number) and paginated by that same key — pass a page's next_cursor straight back as cursor for the next one. has_more is true exactly when there is a next page; on the last page it is false and next_cursor is null. A cursor contains only letters, digits, - and _, so it goes into a URL as it is. An unparseable cursor is a 400, never a silent restart from the first page.

A cursor remembers its window. The next page is cut from the start and end the first page used, even if you left them out and the default "last 24 hours" has moved on since. Beside a cursor you may leave start, end and unknown_time out; if you send them, they must be the values the first page was asked with (the same instant, in any format), or the call is a 400 validation_error.

Names and units. Every measurement is named for its quantity and its unit, in the base unit: voltage_v (volts), current_a (amperes), power_w (real, or active, power in watts), frequency_hz (hertz) and power_factor (no unit). Energy is in watt-hours everywhere (energy_wh), times end in _at, and durations are in seconds. The same names are used on every BluEM route.

Every measurement is rounded to 6 decimal places — past the finest stored resolution — and is null, not zero, when the record carries no reading for that quantity: a quarantined record, or one where the meter went unanswered (reads.n = 0). current_a, power_w and energy_wh are corrected for CT turns: the meter reads ct_turns × the real current when the load conductor is wrapped through its current transformer more than once (emCtTurns), and the server divides those three by the record's own ct_turns before storing it — so energy_wh is a number, not an integer, and ct_turns is carried on every record (1 for firmware that never sent it; null on a quarantined record whose value was unusable, whose current_a, power_w and energy_wh are then null too). voltage_v, frequency_hz and power_factor are as measured. A change of emCtTurns closes the interval in progress (flagged partial), so no record mixes two values. A record whose start time could not be determined (the device rebooted before it ever learned the time) has "start_at": null and is never included unless you pass unknown_time=true, in which case only such records come back, ordered by sequence number and filtered by when they arrived rather than by start/end (they have no start to filter by).

GET /v1/bluem/devices/{id}/summary

Requires energy data read. A window's statistics, added up by the server so you do not have to page through …/intervals. Query params: start/end, exactly as …/intervals takes them (the last 24 hours by default, at most a year).

curl "https://api.blunotify.com/v1/bluem/devices/EM123/summary?start=2026-09-29T14:00:00Z&end=2026-09-30T14:00:00Z" \
  -H "Authorization: Bearer $KEY"

200 {"device_id":"EM123",
     "window":{"start":"2026-09-29T14:00:00+00:00","end":"2026-09-30T14:00:00+00:00"},
     "coverage":{"records":96,"quarantined":0,"seconds":86391},
     "voltage_v":   {"avg":123.077838,"min":119.6,"max":125.3, "intervals":96,"seconds":86391},
     "current_a":   {"avg":0.336163,  "min":0.0,  "max":6.9894,"intervals":96,"seconds":86391},
     "power_w":     {"avg":31.99751,  "min":0.0,  "max":764.92,"intervals":96,"seconds":86391},
     "frequency_hz":{"avg":59.967292, "min":59.9, "max":60.0,  "intervals":96,"seconds":86391},
     "power_factor":{"avg":0.693137,  "min":0.0,  "max":0.96,  "intervals":55,"seconds":49493},
     "energy_wh":{"total":769.0,"resets":0,"intervals":96}}
FieldMeaning
Which recordsExactly the records GET …/intervals lists for the same start and end. A record with no start time is in no window, and this route takes no unknown_time.
voltage_v, current_a, power_w, frequency_hz, power_factorOver the records that carry the quantity (those whose record document has it non-null, with a positive duration_s): avg is the duration-weighted average, min and max the records' own lowest min and highest max, rounded to 6 places like the records; intervals and seconds say how many records and how much time that is. null, never zeros, when no record in the window carries the quantity.
power_factorCounts only records with a load: those whose highest power before CT correction (power_w.max × ct_turns) reached 5 W. With nothing drawing, the meter reports a placeholder power factor (0 or 1), not a real one, and averaging those in would describe how long the load was off rather than the load. So power_factor.intervals is often lower than the others, and power_factor is null for a window with no load at all. It is still a duration-weighted average of the records' own averages, not an energy-weighted power factor: a record whose load ran for part of its interval averages its idle reads in, and its min can be 0.
energy_whtotal, the consumption in watt-hours, with resets and intervals: the same as the total GET …/energy gives for the same window, or null when no record carried a counter reading.
coverageThe window's records, how many of them are quarantined, and the seconds the rest cover. Compare seconds with the window's length to see gaps.
Late recordsLike …/energy totals, a summary of a past window changes when records the meter stored through an outage arrive.

There is no duty cycle or other analysis in the summary: that is yours to compute from …/intervals.

Pulling only what is new: the arrival feed

Requires energy data read. The same route, asked a different way: instead of a window of start times, you ask for everything the server has stored since a point you keep. The server gives you a bookmark with every answer. Follow the bookmark and you receive every record exactly as it is stored, late ones included, and never miss one.

  1. First call. Pass received_since: the time to start from (ISO-8601 or epoch seconds). You get the records the server received at or after it.
  2. Keep the bookmark. Every answer carries next_cursor. It is never null in this mode, even when there are no records. Save it.
  3. Every later call. Pass cursor=<the saved bookmark> and nothing else (limit is allowed). While has_more is true, call again at once with the new bookmark. When it is false you have everything; call again whenever you next want to check.
curl "https://api.blunotify.com/v1/bluem/devices/EM123/intervals?received_since=2026-09-27T00:00:00Z" \
  -H "Authorization: Bearer $KEY"

200 {"device_id":"EM123","mode":"received",
     "received_since":"2026-09-27T00:00:00+00:00",
     "records":[ …record documents, as above… ],
     "has_more":false,
     "next_cursor":"cnw4MDgxNzMwMXwxYTJiM2M0ZHwxMTh8MTc5MDU3MDAwMA"}

curl "https://api.blunotify.com/v1/bluem/devices/EM123/intervals?cursor=cnw4MDgxNzMwMXwxYTJiM2M0ZHwxMTh8MTc5MDU3MDAwMA" \
  -H "Authorization: Bearer $KEY"

200 {"device_id":"EM123","mode":"received","received_since":null,
     "records":[],"has_more":false,
     "next_cursor":"cnw4MDgxNzMwMXwxYTJiM2M0ZHwxMTh8MTc5MDU3MDAwMA"}
RuleDetail
OrderRecords come in the order the server stored them, not by start time. A record that started last Tuesday and arrived today comes after yesterday's.
What is includedEvery record of the meter: those with no start time ("start_at": null) and quarantined ones too. Each record says which it is.
The bookmark is opaqueSend it back exactly as you received it. Do not build or edit one.
received_since with a cursorIgnored. The cursor alone decides where the page starts, so a loop that sends both on every call is fine.
Mixing the two ways of askingstart, end or unknown_time together with received_since or its cursor is a 400.
A short delayA record is served as soon as the server has finished storing everything older than it. That is normally well under a second after it arrives.
Records can repeatAfter you restart from received_since you receive records you already have. Key on sid and seq and the repeats fall away.
A bookmark that stops workingIf a bookmark you were given is answered 400 invalid 'cursor', the server's database was restored from a backup and the bookmark belongs to the time before. Start again with received_since set to an hour before your last successful pull, and drop the repeats.
Shop testingMeters are tested before they ship, and the records a meter stored during the test may be cleared. Cleared records are no longer listed in either mode, and records the meter made before the clear are not stored if they arrive later. A bookmark stays valid. Records you have already pulled are not withdrawn, so a meter you follow from before it ships can show test records ahead of the clear.

BluEM settings. A meter's settings are written with the same PUT /v1/devices/{id}/system/settings/{key} as any device (system control). On a BluEM device the meter keys — emReportSec, emSampleMs, emAlign, emAckTmoSec, emBatchMax, emMaxBytes — are checked against the product's settings catalog before anything is sent, so an out-of-range value is a 400 validation_error rather than a value the meter quietly clamps; the retired emLiveSec and pmPollSec are refused the same way. These keys take effect on the meter within seconds, with no reboot. For a report every five minutes: PUT /v1/devices/EM123/system/settings/emReportSec {"value": "300"} (10–86400; a shorter interval uses more cellular data).

Four keys cannot be written through this route: emAck, pmBaud, pmSlave and emCtTurns. They are fixed by the meter hardware and by how the unit is wired, and a wrong value fails silently: emAck=0 removes the delivery guarantee, a wrong pmBaud or pmSlave stops every reading, and a wrong emCtTurns rescales every reading. The answer is 403 forbidden with the reason, whatever your permissions, and nothing is sent. Every record still reports the ct_turns it was measured under.

GET /v1/bluem/devices/{id}/energy

Requires energy data read. Query params: bucket — hour or day, default hour — and start/end as above. The buckets you get are always exactly the width you asked for, never silently coarser: a window the bucket cannot cover in under 2000 buckets is a 400 validation_error, so an hour-bucketed range must span fewer than 2000 hours (83 days) — ask for bucket=day beyond that.

Your own days and hours: tz. Without it, a day is a UTC day and an hour a UTC hour, exactly as before. Pass tz with an IANA zone name (tz=America/New_York) and the buckets are that zone's local days or hours, so a daily total matches what the Energy Monitoring page shows a reader in that zone. Nothing is stored: send it on every request that should use it.

With tzDetail
start_atEach point's start_at is its bucket's first instant written in that zone, with its offset: 2026-11-01T00:00:00-04:00. The answer echoes tz.
Daylight savingThe day the clocks change is 23 or 25 hours long. With bucket=hour the repeated hour is two buckets and the skipped hour is none.
Zones off the hourIn India, Nepal or Newfoundland an hour begins at :30 or :45 UTC.
The windowStill start to end. If it begins or ends inside a bucket, that bucket covers only the part inside the window, as in UTC. Align start and end to local midnights for whole days. An interval's consumption counts in the bucket its midpoint falls in, with or without tz.
An unknown zone400 validation_error.
curl "https://api.blunotify.com/v1/bluem/devices/EM123/energy?bucket=day&tz=America/New_York&start=2026-10-31T04:00:00Z&end=2026-11-02T05:00:00Z" \
  -H "Authorization: Bearer $KEY"

200 {"device_id":"EM123","bucket":"day","tz":"America/New_York",
     "window":{"start":"2026-10-31T04:00:00+00:00","end":"2026-11-02T05:00:00+00:00"},
     "points":[{"start_at":"2026-10-31T00:00:00-04:00","energy_wh":6624.0,"reset":false},
               {"start_at":"2026-11-01T00:00:00-04:00","energy_wh":6911.0,"reset":false}],
     "total":{"energy_wh":13535.0,"resets":0,"intervals":196,"buckets":2}}
{"device_id":"EM123","bucket":"day",
 "window":{"start":"…","end":"…"},
 "points":[{"start_at":"2026-09-25T00:00:00+00:00","energy_wh":6624.0,"reset":false}],
 "total":{"energy_wh":6624.0,"resets":0,"intervals":96,"buckets":1}}

total is null — not zero — when no record in the window carried a counter reading at all (a brand-new meter, or one silent the whole window): "no readings" and "used nothing" are different facts. A bucket's reset is true if the meter's lifetime counter was zeroed during it (by cmd/resetenergy below, or a factory reset); that bucket's own consumption is counted from zero rather than showing a false negative. Consumption is in watt-hours (Wh), corrected for CT turns like a record's energy_wh, and a change of emCtTurns is never reported as a reset: the interval after the change counts its own first-to-last reading, because the meter's lifetime counter does not restart when the turns change.

POST /v1/bluem/devices/{id}/report

Requires system control. Asks the meter to close the interval in progress right now and send it, instead of waiting for its reporting period to elapse. Fire-and-forget — 202 with the topic/payload/sent_at, same shape as a System API command — and the record it produces carries a partial flag and reaches GET …/intervals a few seconds later; nothing is lost.

This is not a refresh. Every call stores a short record and starts a new interval, so calling it on a timer turns a 15-minute history into fragments and spends cellular data. Use it once in a while, when you need the interval so far on record. To see what the meter reads now, use the live reading below.

Live reading

What the meter reads right now: volts, amps, watts, power factor and frequency. It closes no interval and stores no record, so it is safe to use when a reading is wanted, for example to check an installation. The server never waits for the meter: you ask, you get a token, and you check back with the token. An answer normally takes one to three seconds on cellular.

POST /v1/bluem/devices/{id}/sample

Requires energy data read. No body.

curl -X POST https://api.blunotify.com/v1/bluem/devices/EM123/sample \
  -H "Authorization: Bearer $KEY"

202 {"device_id":"EM123","token":"5f3a9c1e","state":"pending",
     "requested_at":"2026-09-28T14:02:11.482+00:00","timeout_s":30}
AnswerMeaning
202The request was sent to the meter. Check back with the token.
409 device_offlineThe meter is not connected. Nothing was sent. The answer carries status, status_changed_at and last_seen_at.
429This meter was asked less than 10 seconds ago, by you or by anyone else (the limit is per meter). Retry-After says how long to wait.
503 publish_failedThe server could not reach its message broker. Try again.
GET /v1/bluem/devices/{id}/sample/{token}

Requires energy data read. Ask every second or two until state is no longer pending. It is final within 30 seconds. The 202 from POST …/sample names this URL in its Location header.

curl https://api.blunotify.com/v1/bluem/devices/EM123/sample/5f3a9c1e \
  -H "Authorization: Bearer $KEY"

200 {"device_id":"EM123","token":"5f3a9c1e","state":"done","reason":null,
     "requested_at":"2026-09-28T14:02:11.482+00:00",
     "reading":{"link":"ok","answered":true,
                "voltage_v":122.0,"current_a":0.6268,"power_w":58.54,
                "power_factor":0.77,"frequency_hz":60.0,
                "ct_turns":5,"age_s":0,
                "taken_at":"2026-09-28T14:02:12.760+00:00","took_s":1.278}}
stateMeaning
pendingNo answer yet. Ask again.
doneThe meter answered. reading is set.
failedThe meter answered that it did not run the request. reason says why: busy (ask again in a moment), config_mode, unsupported, rejected or no_snapshot.
no_replyNothing came back within 30 seconds. The meter may have just gone offline.
Field of readingMeaning
voltage_v, frequency_hz, power_factorAs measured. power_factor is null when nothing is drawing power.
current_a, power_wThe real load: already divided by ct_turns. Do not divide again.
ct_turnsThe CT turns the unit is set to.
link, answeredWhether the unit's meter answered its last read. link is ok, timeout, crc, exception, badreply, notstarted or unknown; answered is true only for ok.
age_sHow many seconds before the answer the meter read these values. Normally 0 or 1. When answered is false the values are the last successful read and age_s says how old it is.
taken_at, took_sWhen the answer reached the server, and how many seconds after your request.

A quantity the meter has no value for is null, never zero: a meter with no voltage on its leads does not answer, and that is not the same as a load drawing nothing. A token that was not issued for this meter is 404, exactly like an unknown meter. The reading is not stored as an interval record and does not appear in GET …/intervals.

POST /v1/bluem/devices/{id}/resetenergy

Requires energy reset — a flag of its own, off by default and independent of every other BluEM/System flag — plus a body {"confirm": "<device-id>"} matching the device id exactly, the same as a destructive System API verb. A missing or mismatched confirm is 409 confirmation_required and nothing is sent.

Zeroes the meter's lifetime energy counter. This cannot be undone. The request is recorded before anything is published, and the server mints a token so a redelivered command cannot zero the counter a second time.
curl -X POST https://api.blunotify.com/v1/bluem/devices/EM123/resetenergy \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"confirm":"EM123"}'

202 {"topic":"bluem/EM123/cmd/resetenergy","payload":"5b1e…-token",
     "sent_at":"…","token":"5b1e…-token"}
GET /v1/bluem/devices/{id}/resetenergy/{token}

Requires energy reset (the same flag as the request). Poll with the token from the 202 above for the outcome; that 202 names this URL in its Location header:

stateMeaning
pendingNo answer yet. Keep polling.
doneThe counter was zeroed.
duplicateThis token had already been used — the counter was not reset again.
failedThe meter reported an error; the counter is unchanged and the request may be repeated.

A token never issued for this device is 404, exactly like an unknown or out-of-scope device — it cannot be used to read another meter's log.

BluEM commands are never retained on the broker (unlike BluNotify's light2/light3/sound effects, which the broker replays to a reconnecting device on purpose): report, resetenergy and the live reading's request are one-shot events, and a retained copy would re-fire on every reconnect for the life of the device.

Demo access

Before production access, BluNotify staff provision a demo partner and a demo light, and issue you a key. A typical first session:

  1. GET /v1/account — confirm your permissions.
  2. GET /v1/devices — find your demo device id and channels.
  3. GET /v1/commands — see the commands and parameter ranges granted to you.
  4. POST /v1/devices/{id}/channels/light2/effects with {"command":"flash_white"} — trigger a visible overlay.

Contact your BluNotify representative to arrange demo credentials.

Extending predefined commands

New named commands are added by BluNotify developers at management's request (they are implemented in code, reviewed, and tested, then granted to your account). If you need a specific effect, describe the behavior to your representative and it can be added as a new command without changing your integration — it simply appears in GET /v1/commands once granted.

Versioning and deprecation

Until /v1 launches, it may still change without notice. These rules apply from launch.

/v1 only gains things. After launch, /v1 changes only by adding: new routes, new optional parameters, new response fields, new error codes. Anything that would break a working client goes into a new version (/v2), and /v1 keeps working beside it. Every cursor and bookmark already issued keeps working too.

What counts as breaking:

Your client must ignore what it does not know. Additions are only safe if clients allow for them: ignore response fields you do not recognise, handle an error code you do not know by its HTTP status, and do not fail on an enum value you have not seen.

Deprecation. Something that is to be removed is deprecated first, and keeps working for at least six months before its sunset date. When it is announced:

Watch for the Deprecation header in your logs: it is the earliest signal a call you rely on is going away.