Events WebSocket
Events WebSocket
The e-bon backend exposes a single real-time channel that pushes device.*, receipt.*, command.* and app.* events to subscribers as they happen.
wss://api.e-bon.ro/ws?subscribe=events
This page is the wire-protocol reference for integrators connecting from any language (Python, Go, PHP, raw wscat, browser WebSocket, etc.).
Choose a delivery channel
e-bon offers three ways to learn that something happened on a device or in your organization. They are not mutually exclusive — most production integrations subscribe to webhooks for durability and to this WebSocket for low-latency UX.
| Channel | Transport | Latency | Durability | Use for |
|---|---|---|---|---|
| Events WebSocket | Persistent WSS push | < 100 ms | None — events sent while disconnected are lost | Live dashboards, POS UX, dev tools |
| Webhooks | HTTPS POST per event | ~ seconds | Durable retry with [60s, 5m, 30m, 2h, 12h] backoff | See Webhooks |
| Polling | GET /api/v1/... | poll-rate | Pull-driven | Last resort when neither push channel is reachable |
Connect with a JWT
First-party tooling that already holds a Firebase-issued access token connects with a token query parameter:
wss://api.e-bon.ro/ws?subscribe=events&token=<jwt>
Portal subscribers receive every event for their organization — no per-scope filter is applied.
import WebSocket from 'ws';
const ws = new WebSocket(
`wss://api.e-bon.ro/ws?subscribe=events&token=${jwt}`
);
ws.on('open', () => console.log('connected'));
ws.on('message', (raw) => {
const event = JSON.parse(raw.toString());
console.log(event.type, event.data);
});
ws.on('close', (code, reason) => {
console.log('closed', code, reason.toString());
});
import asyncio, json, websockets
async def run(jwt: str):
url = f"wss://api.e-bon.ro/ws?subscribe=events&token={jwt}"
async with websockets.connect(url) as ws:
async for raw in ws:
event = json.loads(raw)
print(event["type"], event["data"])
asyncio.run(run("<jwt>"))
package main
import (
"fmt"
"github.com/gorilla/websocket"
)
func main() {
url := "wss://api.e-bon.ro/ws?subscribe=events&token=" + jwt
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
panic(err)
}
defer ws.Close()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
return
}
fmt.Println(string(msg))
}
}
wscat -c "wss://api.e-bon.ro/ws?subscribe=events&token=<jwt>"
Connect with an API key
Server-to-server integrations holding an ebon_live_* API key connect with the apiKey query parameter:
wss://api.e-bon.ro/ws?subscribe=events&apiKey=ebon_live_<key>
Partner subscribers receive only events permitted by the key's scopes (see Filter by scope below).
import WebSocket from 'ws';
const ws = new WebSocket(
`wss://api.e-bon.ro/ws?subscribe=events&apiKey=${apiKey}`
);
ws.on('message', (raw) => {
const event = JSON.parse(raw.toString());
console.log(event.type, event.data);
});
import asyncio, json, websockets
async def run(api_key: str):
url = f"wss://api.e-bon.ro/ws?subscribe=events&apiKey={api_key}"
async with websockets.connect(url) as ws:
async for raw in ws:
event = json.loads(raw)
print(event["type"], event["data"])
asyncio.run(run("ebon_live_..."))
<?php
require 'vendor/autoload.php';
use WebSocket\Client;
$url = "wss://api.e-bon.ro/ws?subscribe=events&apiKey={$apiKey}";
$client = new Client($url);
while (true) {
$raw = $client->receive();
$event = json_decode($raw, true);
echo $event['type'] . PHP_EOL;
}
wscat -c "wss://api.e-bon.ro/ws?subscribe=events&apiKey=ebon_live_<key>"
token and apiKey in the same connection.Authentication failures
If authentication fails, or if neither token nor apiKey is provided, the server closes the connection immediately with code 4001 and one of these literal close reasons:
| Trigger | Close reason |
|---|---|
| Invalid or expired JWT | Invalid or expired token |
| Invalid or inactive API key | Invalid or inactive API key |
| Unexpected error during API-key check | Authentication error |
Neither token nor apiKey provided | Authentication required |
Receive events
Every event the server sends is a single text WebSocket frame containing one JSON object:
{
"type": "<event-type>",
"data": { /* event-specific fields */ },
"timestamp": "2026-04-26T08:09:55.123Z"
}
timestamp is an ISO 8601 string captured at dispatch time. There is no envelope, no id, no per-event ack — frames are fire-and-forget.
Event catalogue
There are eight event types:
type | Meaning | Typical data fields |
|---|---|---|
device.status | A device's online/offline status changed. | deviceId, status |
device.claimed | A previously-unclaimed device was bound to your organization. | deviceId, claimedAt |
device.alert | A device-level alert was raised (low paper, drawer open, error condition). | deviceId, severity, message |
receipt.created | A fiscal or non-fiscal receipt was emitted. | receiptId, deviceId, total, currency |
command.completed | A queued command finished successfully on the device. | commandId, deviceId, result |
command.failed | A queued command failed on the device. | commandId, deviceId, errorCode, errorMessage |
app.connected | A device-app instance opened a WebSocket to the cloud. | deviceId, connectedAt |
app.disconnected | A device-app instance closed (cleanly or by heartbeat timeout). | deviceId, disconnectedAt, reason |
For the canonical per-event payload schemas, see Webhook events — the WebSocket and webhooks emit the same data object for matching event types.
Example frames
{ "type": "device.status", "data": { "deviceId": "dev_pos_01", "status": "online" }, "timestamp": "2026-04-26T08:09:55.123Z" }
{ "type": "receipt.created", "data": { "receiptId": "rcp_abc", "deviceId": "dev_pos_01", "total": 4250, "currency": "RON" }, "timestamp": "2026-04-26T08:10:01.456Z" }
{ "type": "command.completed", "data": { "commandId": "cmd_xyz", "deviceId": "dev_pos_01", "result": { "fiscalId": "F00012345" } }, "timestamp": "2026-04-26T08:10:02.789Z" }
Filter by scope
Partner subscribers (API-key auth) receive only events whose type starts with one of the prefixes mapped from the key's scopes. Portal subscribers (JWT auth) bypass this filter entirely and see every event in the organization.
| API-key scope | Allowed event-type prefixes |
|---|---|
receipts | receipt. |
receipts:read | receipt. |
receipts:admin | receipt. |
devices | device. |
devices:read | device. |
devices:write | device. |
commands | command. |
all | device., receipt., command., app. |
The check is OR across scopes — if any of the key's scopes maps to a prefix that the event's type starts with, the event is delivered. A key with no recognized scopes receives nothing.
app.* events (app.connected, app.disconnected) are reachable only via the all scope. There is no narrower scope mapped to the app. prefix.The endpoint has no client→server subscribe protocol — you cannot narrow the filter beyond what the credential grants. To consume only one event type, filter on event.type in your client code.
For the full scope catalogue, see API authentication › Scopes.
Handle the heartbeat
The server sends a binary WebSocket-protocol ping frame to every active subscription every 30 seconds and waits 10 seconds for the matching pong. If the pong does not arrive in time, the server terminates the connection.
{"type":"ping"} JSON message. Most clients respond automatically:- Browser
WebSocket— handled by the browser, not exposed to JS. - Python
websockets— responds automatically. Do not setping_interval=Noneunless you implement your own pong handler. - Node
ws— responds automatically. - Go
gorilla/websocket— callSetPongHandlerand reply from your read loop. wscat— responds automatically.
The endpoint is push-only after connect — there is no client→server subscribe JSON message and no per-event ack. Subscribers should stay silent.
Stay under the rate limit
The server installs a per-connection inbound throttle of 20 messages per second. Excess inbound messages are silently dropped — no error frame is sent and the connection stays open. Since the protocol is push-only, normal clients never trigger this limit.
Reconnect after a disconnect
The server does not push reconnect tokens, session IDs, or resume cursors. Once a client disconnects (clean close, network drop, heartbeat timeout, or auth failure), events emitted while the client was off the wire are never replayed on the next connection. Use Webhooks if you need durable delivery.
The recommended client-side strategy is exponential backoff with a per-attempt cap:
- Start with a 1-second delay after the first disconnect.
- Double the delay on every consecutive failure (factor 2).
- Cap at 30 seconds.
- Reset the delay back to 1 second after a successful connect.
- On a
4001close, do not retry until the operator has rotated the offending credential — repeated4001s mean the JWT is expired or the API key has been disabled.
import WebSocket from 'ws';
let delay = 1000;
const max = 30_000;
function connect() {
const ws = new WebSocket(
`wss://api.e-bon.ro/ws?subscribe=events&apiKey=${apiKey}`
);
ws.on('open', () => { delay = 1000; });
ws.on('message', (raw) => handle(JSON.parse(raw.toString())));
ws.on('close', (code) => {
if (code === 4001) return; // rotate credential before retrying
setTimeout(connect, delay);
delay = Math.min(delay * 2, max);
});
}
connect();
import asyncio, json, websockets
async def run(api_key: str):
url = f"wss://api.e-bon.ro/ws?subscribe=events&apiKey={api_key}"
delay = 1
while True:
try:
async with websockets.connect(url) as ws:
delay = 1
async for raw in ws:
handle(json.loads(raw))
except websockets.ConnectionClosed as exc:
if exc.code == 4001:
return # rotate credential before retrying
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
Close codes
| Code | When |
|---|---|
1000 | Either side initiated a clean close (e.g. you called ws.close() cleanly). |
1001 | Server graceful shutdown — close reason Server shutting down. |
1006 | Network drop, TCP reset, or the server terminated the connection after a heartbeat-pong timeout. No close frame is sent. |
4001 | Authentication failure — invalid/expired JWT, invalid/inactive API key, missing auth, or an unexpected error during the API-key check. See the close reasons under Authentication failures. |
Where to next
Idempotency
Use the Idempotency-Key header to make POS retries safe — replay cached responses for 24 hours and avoid double-printed receipts.
Authentication
How to authenticate against the e-bon API — API key format, the nine scopes, JWT for portal sessions, common auth errors and ready-to-paste curl examples.