{"openapi":"3.0.3","info":{"title":"OPF BESS API","version":"1.0.0","description":"Accounts, users, storage sites, live MQTT telemetry and dispatch control."},"servers":[{"url":"https://api.opf.lt"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"},"apiKey":{"type":"apiKey","in":"header","name":"X-API-Key"}}},"paths":{"/health":{"get":{"summary":"Liveness and dependency check. Returns 503 when MongoDB or MQTT is down, so it can drive a load balancer or uptime monitor.","tags":["System"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"status":"ok","service":"opf-api","ts":"2026-08-13T20:23:45.834Z","mongo":true,"mqtt":{"enabled":true,"connected":true,"topics":["storage/+/rt","storage/+/status","rut/+/telemetry"]}}}}}}}},"/api/v1/meta":{"get":{"summary":"Service metadata and a machine readable list of every route.","tags":["System"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"service":"opf-api","version":"1.0.0","timezone":"Europe/Vilnius","endpoints":["POST   /api/v1/auth/register","POST   /api/v1/auth/login","..."]}}}}}}},"/api/v1/auth/register":{"post":{"summary":"Creates an account and its first owner user. Allowed only when no users exist yet (first run bootstrap) or when ALLOW_SIGNUP is enabled. Password must be at least 12 characters and contain lower case, upper case and a digit.","tags":["Authentication"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"user":{"id":"6a7e2814d2f9fdca48904304","accountId":"6a7e2813d2f9fdca48904303","email":"admin@opf.lt","name":"Admin","role":"owner","active":true,"createdAt":"2026-08-13T20:24:51.796Z","lastLoginAt":null},"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","refreshToken":"K9mZ3Xq...","expiresAt":"2026-09-12T20:24:51.812Z"}}}}}}},"/api/v1/auth/login":{"post":{"summary":"Exchanges credentials for an access token (15 minutes) and a refresh token (30 days). Limited to 10 attempts per 15 minutes per IP. Unknown email and wrong password return the same error.","tags":["Authentication"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"user":{"id":"6a7e2814d2f9fdca48904304","accountId":"6a7e2813d2f9fdca48904303","email":"admin@opf.lt","name":"Admin","role":"owner","active":true,"createdAt":"2026-08-13T20:24:51.796Z","lastLoginAt":"2026-08-13T21:02:10.114Z"},"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","refreshToken":"K9mZ3Xq...","expiresAt":"2026-09-12T21:02:10.130Z"}}}}}}},"/api/v1/auth/refresh":{"post":{"summary":"Rotates the refresh token and returns a fresh access token. Refresh tokens are single use: the old one is destroyed, so replaying a stolen token fails.","tags":["Authentication"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","refreshToken":"Pq7Rt2...","expiresAt":"2026-09-12T21:14:03.552Z"}}}}}}},"/api/v1/auth/logout":{"post":{"summary":"Destroys the supplied refresh token. Always returns success so it cannot be used to probe token validity.","tags":["Authentication"],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ok":true}}}}}}},"/api/v1/auth/me":{"get":{"summary":"Returns the caller identity and their account. With an API key it reports the key role instead of a user.","tags":["Authentication"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"user":{"id":"6a7e2814d2f9fdca48904304","accountId":"6a7e2813d2f9fdca48904303","email":"admin@opf.lt","name":"Admin","role":"owner","active":true,"createdAt":"2026-08-13T20:24:51.796Z","lastLoginAt":"2026-08-13T21:02:10.114Z"},"account":{"id":"6a7e2813d2f9fdca48904303","name":"OPF","slug":"opf","plan":"standard"}}}}}}}},"/api/v1/auth/password":{"post":{"summary":"Changes your own password and signs out every other session. Not available to API keys.","tags":["Authentication"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ok":true,"message":"password updated, other sessions signed out"}}}}}}},"/api/v1/accounts/current":{"get":{"summary":"Your account profile plus usage counters.","tags":["Accounts"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"account":{"id":"6a7e2813d2f9fdca48904303","name":"OPF","slug":"opf","plan":"standard","active":true,"createdAt":"2026-08-13T20:24:51.780Z","contact":{"email":"info@opf.systems","phone":"","address":"Kalviai, Lithuania"}},"usage":{"users":3,"sites":8}}}}}}},"patch":{"summary":"Updates the account display name and contact block.","tags":["Accounts"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"account":{"id":"6a7e2813d2f9fdca48904303","name":"OPF Energy","slug":"opf"}}}}}}}},"/api/v1/accounts/current/audit":{"get":{"summary":"Audit trail of account changes: logins, user and site changes, commands, API key lifecycle. Retained 365 days.","tags":["Accounts"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"entries":[{"ts":"2026-08-13T21:05:11.004Z","action":"site.command","actorId":"6a7e2814d2f9fdca48904304","target":"sasiai"},{"ts":"2026-08-13T21:04:02.881Z","action":"site.created","actorId":"6a7e2814d2f9fdca48904304","target":"sasiai"},{"ts":"2026-08-13T21:02:10.120Z","action":"auth.login","actorId":"6a7e2814d2f9fdca48904304","target":null}]}}}}}}},"/api/v1/accounts/current/api-keys":{"get":{"summary":"Lists active keys. Only the prefix is stored in readable form; the secret cannot be recovered.","tags":["API keys"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"apiKeys":[{"id":"6a7e2999d2f9fdca48904311","name":"grafana-readonly","prefix":"opf_9Kd2xQ","role":"viewer","createdAt":"2026-08-13T21:10:44.201Z","lastUsedAt":"2026-08-13T21:12:03.559Z"}]}}}}}},"post":{"summary":"Creates a machine credential for dashboards or scripts. The plaintext key is returned exactly once. Send it later in the X-API-Key header. You cannot grant a role above your own.","tags":["API keys"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"apiKey":{"id":"6a7e2999d2f9fdca48904311","name":"grafana-readonly","prefix":"opf_9Kd2xQ","role":"viewer"},"key":"opf_9Kd2xQ7bTnVw5sLpAeR3yUfHgKmZcXdQ2NvBjWtE"}}}}}}},"/api/v1/accounts/current/api-keys/{id}":{"delete":{"summary":"Revokes a key immediately. Revoked keys are kept for audit purposes but never authenticate again.","tags":["API keys"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ok":true}}}}}}},"/api/v1/users":{"get":{"summary":"Lists users in your account. Password hashes are never returned.","tags":["Users"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"users":[{"id":"6a7e2814d2f9fdca48904304","accountId":"6a7e2813d2f9fdca48904303","email":"admin@opf.lt","name":"Admin","role":"owner","active":true,"createdAt":"2026-08-13T20:24:51.796Z","lastLoginAt":"2026-08-13T21:02:10.114Z"}]}}}}}},"post":{"summary":"Invites a user into your account with a starting password. You cannot create a user with a role above your own.","tags":["Users"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"user":{"id":"6a7e2a55d2f9fdca48904308","accountId":"6a7e2813d2f9fdca48904303","email":"operator@opf.lt","name":"Field Operator","role":"operator","active":true,"createdAt":"2026-08-13T21:20:31.442Z","lastLoginAt":null}}}}}}}},"/api/v1/users/{id}":{"get":{"summary":"Fetches one user from your account. Users in other accounts return 404, never 403.","tags":["Users"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"user":{"id":"6a7e2a55d2f9fdca48904308","email":"operator@opf.lt","role":"operator","active":true}}}}}}},"patch":{"summary":"Updates name, role, active flag or password. Users above your own role cannot be modified.","tags":["Users"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"user":{"id":"6a7e2a55d2f9fdca48904308","email":"operator@opf.lt","role":"admin","active":true}}}}}}},"delete":{"summary":"Removes a user and all of their sessions. You cannot delete yourself.","tags":["Users"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ok":true}}}}}}},"/api/v1/sites":{"get":{"summary":"Lists every site in your account with its latest telemetry attached. A site counts as online when telemetry arrived within the stale window (default 300 seconds).","tags":["Sites"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"sites":[{"siteId":"sasiai","name":"OPF Sasiai","location":"Kalviai","capacityKwh":150,"maxChargeKw":30,"maxDischargeKw":30,"gridExportKw":30,"minSocPct":5,"maxSocPct":100,"mode":"auto","minExportPrice":115,"timezone":"Europe/Vilnius","active":true,"createdAt":"2026-08-13T21:04:02.870Z","online":true,"latest":{"ts":"2026-08-13T21:25:07.285Z","metrics":{"gridKw":0.02,"pvKw":0,"loadKw":0.11,"batteryKw":-0.2,"socPct":21,"tempC":27}}}]}}}}}},"post":{"summary":"Registers a site. The siteId must match the wildcard segment your hardware publishes on, for example storage/sasiai/rt. Telemetry from unregistered site ids is discarded.","tags":["Sites"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"site":{"siteId":"sasiai","name":"OPF Sasiai","capacityKwh":150,"maxChargeKw":30,"maxDischargeKw":30,"mode":"auto","active":true,"online":false,"latest":null}}}}}}}},"/api/v1/sites/{siteId}":{"get":{"summary":"One site with its latest telemetry.","tags":["Sites"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"site":{"siteId":"sasiai","name":"OPF Sasiai","capacityKwh":150,"mode":"auto","online":true,"latest":{"ts":"2026-08-13T21:25:07.285Z","metrics":{"gridKw":0.02,"socPct":21,"batteryKw":-0.2}}}}}}}}},"patch":{"summary":"Updates site configuration. Send only the fields you want changed.","tags":["Sites"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"site":{"siteId":"sasiai","minExportPrice":120,"maxDischargeKw":28,"mode":"auto"}}}}}}},"delete":{"summary":"Deletes the site and its latest telemetry record. Historical points expire on their own retention schedule.","tags":["Sites"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ok":true}}}}}}},"/api/v1/sites/{siteId}/command":{"post":{"summary":"Queues a dispatch command. The API does not touch the device: the dispatcher agent claims the command, publishes it to storage/{siteId}/cmd at QoS 1 and tracks the acknowledgement. Returns 202 with a requestId you can poll on /api/v1/commands/:requestId. powerKw is validated against the site limit both here and again by the agent at send time.","tags":["Sites"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ok":true,"command":{"requestId":"sasiai-1786655498795-7fda52","siteId":"sasiai","action":"export","powerKw":25,"status":"queued","createdAt":"2026-08-13T21:11:38.795Z"}}}}}}}},"/api/v1/commands":{"get":{"summary":"Recent commands for your account, newest first. Use it to follow a dispatch from queued through to acknowledged.","tags":["Commands"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"commands":[{"requestId":"sasiai-1786655498795-7fda52","siteId":"sasiai","action":"export","powerKw":25,"status":"acked","attempts":1,"createdAt":"2026-08-13T21:11:38.795Z","sentAt":"2026-08-13T21:11:39.738Z","ackedAt":"2026-08-13T21:11:53.155Z","error":null}]}}}}}}},"/api/v1/commands/{requestId}":{"get":{"summary":"One command with the raw acknowledgement payload the device returned.","tags":["Commands"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"command":{"requestId":"sasiai-1786655498795-7fda52","siteId":"sasiai","action":"export","powerKw":25,"status":"acked","attempts":1,"createdAt":"2026-08-13T21:11:38.795Z","sentAt":"2026-08-13T21:11:39.738Z","ackedAt":"2026-08-13T21:11:53.155Z","error":null},"ack":{"requestId":"sasiai-1786655498795-7fda52","status":"ok"}}}}}}}},"/api/v1/telemetry/live":{"get":{"summary":"Latest reading for every site in your account. Ideal for a dashboard poll every few seconds.","tags":["Telemetry"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ts":"2026-08-13T20:25:09.501Z","sites":[{"siteId":"sasiai","ts":"2026-08-13T20:25:07.285Z","online":true,"metrics":{"gridKw":0.02,"pvKw":0,"loadKw":0.11,"batteryKw":-0.2,"socPct":21,"tempC":27}}]}}}}}}},"/api/v1/telemetry/live/{siteId}":{"get":{"summary":"Latest reading for one site, including the raw device payload exactly as published.","tags":["Telemetry"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"siteId":"sasiai","ts":"2026-08-13T20:25:07.285Z","metrics":{"gridKw":0.02,"pvKw":0,"loadKw":0.11,"batteryKw":-0.2,"socPct":21,"tempC":27},"payload":{"grid_kw":0.02,"pv_kw":0,"load_kw":0.11,"battery_kw":-0.2,"soc_pct":21,"temp_c":27}}}}}}}},"/api/v1/telemetry/history/{siteId}":{"get":{"summary":"Time series for one site, ascending by timestamp. History is sampled at most once per minute per site and retained for 90 days by default.","tags":["Telemetry"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"siteId":"sasiai","from":"2026-08-13T00:00:00.000Z","to":"2026-08-13T23:59:59.000Z","count":2,"points":[{"ts":"2026-08-13T20:24:07.101Z","metrics":{"gridKw":0.01,"socPct":21,"batteryKw":-0.18}},{"ts":"2026-08-13T20:25:07.285Z","metrics":{"gridKw":0.02,"socPct":21,"batteryKw":-0.2}}]}}}}}}},"/api/v1/telemetry/summary":{"get":{"summary":"Fleet roll up: how many sites are online, aggregate capacity, stored energy derived from each site SOC, and combined power flows.","tags":["Telemetry"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"ts":"2026-08-13T20:25:09.550Z","sites":8,"online":7,"capacityKwh":1211.5,"storedKwh":402.31,"averageSocPct":33.2,"batteryKw":-73.14,"pvKw":0.01,"gridKw":-64.64}}}}}}},"/api/v1/telemetry/stream":{"get":{"summary":"Server sent events. Every ingested message for your account is pushed immediately, with a comment ping every 25 seconds to hold the connection open. Only messages from your own sites are delivered.","tags":["Telemetry"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":"event: ready\ndata: {\"ts\":\"2026-08-13T20:25:00.001Z\"}\n\nevent: telemetry\ndata: {\"siteId\":\"sasiai\",\"ts\":\"2026-08-13T20:25:07.285Z\",\"metrics\":{\"gridKw\":0.02,\"socPct\":21}}\n\n: ping"}}}}}},"/api/v1/telemetry/prices":{"get":{"summary":"Market prices from the shared price collection, used by the optimiser to choose export windows.","tags":["Telemetry"],"security":[{"bearerAuth":[]},{"apiKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"example":{"collection":"price_market","from":"2026-08-14T00:00:00.000Z","to":"2026-08-15T00:00:00.000Z","count":2,"points":[{"start":"2026-08-14T08:00:00.000Z","end":"2026-08-14T08:15:00.000Z","price":177.07},{"start":"2026-08-14T09:00:00.000Z","end":"2026-08-14T09:15:00.000Z","price":174.07}]}}}}}}}}}