Schemas
Schemas
The realtime channel carries two different shapes of message:
- Partner-facing event envelopes —
{ type, data, timestamp }— documented on Events. This is what most integrators care about. - Wire-protocol frames — typed, validated, no
datawrapper — used between the e-bon gateway and the E-BON Android app — see Architecture overview.
You only need this page if you are building a custom client that talks the wire protocol directly. If you are using @e-bon/realtime, the schemas below are validated for you and dispatched through the same on / off / once API.
All schemas are zod schemas exported by the @e-bon/realtime package. Schemas are the source of truth and the TypeScript types are inferred via z.infer.
Server → device messages
The seven message types the server can send to a device:
| Type literal | Payload |
|---|---|
device.updated | { deviceId, patch: Partial<Device>, version: number } |
location.updated | { locationId, patch: Partial<Location>, version: number } |
location.deleted | { locationId } |
receipt.created | { receiptId, receipt: Partial<Receipt> } |
receipt.voided | { receiptId, voidedAt: ISO8601, voidedBy } |
command.dispatch | { commandId, command: Partial<FiscalCommand> } |
sync.resume | { since: ISO8601 } |
The full discriminated union is exported as serverToClientMessageSchema and ServerToClientMessage:
import {
serverToClientMessageSchema,
type ServerToClientMessage,
SERVER_TO_CLIENT_TYPES,
isServerToClientType,
} from '@e-bon/realtime';
const result = serverToClientMessageSchema.safeParse(raw);
if (!result.success) {
// Drop the frame — never trust a partial parse.
return;
}
const message: ServerToClientMessage = result.data;
SERVER_TO_CLIENT_TYPES is the readonly tuple of the seven literals; isServerToClientType(value) is the runtime type guard.
::callout
icon: i-lucide-info
The patch, receipt and command bodies are typed as Record<string, unknown> (jsonObjectSchema) today — @e-bon/types does not yet ship zod mirrors for the core domain models. A tighter typing pass is tracked under E-1c+.
::
Device → server messages
The three message types a device can send back to the server. Every device→server frame must carry a clientMessageId so the server can de-duplicate on at-least-once delivery.
| Type literal | Payload |
|---|---|
command.result | { clientMessageId, commandId, result: Record<string, unknown> } |
command.error | { clientMessageId, commandId, error: Record<string, unknown> } |
status | { clientMessageId, deviceStatus: Record<string, unknown> } |
The discriminated union is exported as clientToServerMessageSchema and ClientToServerMessage:
import {
clientToServerMessageSchema,
type ClientToServerMessage,
CLIENT_TO_SERVER_TYPES,
isClientToServerType,
} from '@e-bon/realtime';
CLIENT_TO_SERVER_TYPES is the readonly tuple of the three literals; isClientToServerType(value) is the runtime type guard.
The clientMessageId boundary
clientMessageId is a non-empty string chosen by the device and is the only field the server uses to de-duplicate frames. The server treats two frames with the same clientMessageId (within the dedup window) as a single frame, so:
- Generate a fresh
clientMessageIdper logical message (UUID v4 is fine). - Reuse the same
clientMessageIdwhen retrying the same logical message after a reconnect — never mint a new one for a retry.
Sending frames from a device
@e-bon/realtime ships a public device→server API. Call it from your client once connected has fired; the alpha behaviour is deliberately minimal — there is no in-memory queue and no retry, so frames sent while the socket is not open are dropped with a console.warn.
// Low-level: build the envelope yourself.
realtime.send({
type: 'command.result',
clientMessageId: 'b4a4f2c7-…', // UUID v4, see the boundary rule above
commandId: 'cmd_01H…',
result: { receiptNumber: 4711 },
});
// Convenience helpers — same wire shape, fewer keystrokes.
realtime.sendCommandResult({
commandId: 'cmd_01H…',
clientMessageId: 'b4a4f2c7-…',
result: { receiptNumber: 4711 },
});
realtime.sendCommandError({
commandId: 'cmd_01H…',
clientMessageId: 'b4a4f2c7-…',
error: { code: 'PRINTER_OFFLINE', message: 'no answer from device' },
});
Behaviour the helpers guarantee:
- Every frame is validated against
clientToServerMessageSchemabefore it touches the wire. Invalid payloads are dropped and logged — a malformed call site can never put garbage on the protocol. - If
readyState !== OPEN(socket closing, suspended, or mid-reconnect), the frame is dropped with aconsole.warnrather than buffered. Callers that need at-least-once semantics must persist the intent themselves and re-send after the nextconnectedevent, reusing the sameclientMessageIdso the server de-duplicates the retry. - No automatic batching, no compression, no implicit
clientMessageIdgeneration — every field on the wire is one you handed in.
This section also documents the wire shape so middleware authors and custom-client builders (Go, Java, Swift, …) can speak the same protocol without depending on @e-bon/realtime.
Shared helpers
iso8601Schema—z.iso.datetime({ offset: true }), used for everytimestampandsincefield.jsonObjectSchema—z.record(z.string(), z.unknown()), the placeholder forDevice/Location/Receipt/FiscalCommandbodies until the core types ship zod mirrors.
Both are exported from @e-bon/realtime.
Where to next
- Events — the partner-facing envelope this page complements.
- Reconnect and lifecycle — backoff, suspend / resume, cursor-store hook.
- Architecture overview — the broader realtime context.
Reconnect and lifecycle
The auto-reconnect schedule, the close / suspend / resume controls, and the CursorStore hook for future at-least-once delivery.
Dashboard
The post-login home of the Portal — a one-screen overview of today's devices and receipts for your active organization, updated live over WebSocket with a 30-second polling fallback.