Skip to content
This page View Markdown Open in ChatGPT Open in Claude

WebSocket events

API Maker runs a WebSocket server on the port of its APIs (38246 on a server set up by the install script, wss:// behind Caddy). A client connects with its tokens, subscribes to what it wants to hear about, and receives a message every time a matching API succeeds on any server of the cluster, or when your code emits a custom event. No polling, no message broker of your own.

WebSockets : connect with the tokens, register with a condition, get notified from any server Your app browser · mobile new WebSocket( wss://…:38246/ ?x-am-authorization= &x-am-user-auth…=) then REGISTER, then listen 1 · CONNECTED 2 · REGISTER { onEvents } 3 · REGISTER { valid, invalid } 4 · NOTIFICATION { eventData } API Maker cluster WebSocket server checks the tokens, keeps the subscriptions with their condition Redis carries every notification to every server, so the client can be anywhere what sends a notification A successful API call of a table, a custom or system API Your code emitEventWS(name, data) or POST …/emit-event-ws The condition { conditionType: 'RESPONSE', criteria: { customer_id: 42 } } Only answers matching the criteria reach this client ; select picks the fields, getEventData sends them Any server of the cluster can serve the socket and any server can trigger the notification.

1. Connect

The tokens go in the query string of the WebSocket URL, with the same names as the request headers.

1
2
3
4
5
const ws = new WebSocket(
    'wss://ws.example.com/'                                  // the WebSocket URL of your API Maker
    + '?x-am-authorization=' + encodeURIComponent(apiUserToken)
    + '&x-am-user-authorization=' + encodeURIComponent(personToken)   // when your events need the person
);
  • x-am-authorization : the API user, always. The person token of the auth provider of the events you subscribe to : x-am-user-authorization, x-google-authorization, x-azure-authorization, x-aws-authorization or x-custom-authorization. API Maker finds out which provider a token belongs to ; &authProviders=name1,name2 narrows it when you want.
  • The first message from the server is CONNECTED :
{ "type": "CONNECTED", "response": { "connected": true } }
  • A bad token closes the connection with a TOKEN_VALIDATION message which says which header is wrong, then the close code 1000 with the reason Invalid client.

2. Subscribe

Send a REGISTER message with the events you want, then read the answer : the ones accepted, with their eventId, and the ones refused with the reason.

ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    if (msg.type === 'CONNECTED') {
        ws.send(JSON.stringify({
            objType: 'REGISTER',
            onEvents: [
                {   // every successful get all on this table
                    eventType: 'INSTANCES', apiName: 'SCHEMA_GET_ALL',
                    instance: 'mongodb', database: 'shop', collection: 'orders',
                    getEventData: true, select: { order_no: 1, status: 1 },
                },
                {   // a custom event, only when the criteria matches the emitted data
                    eventType: 'CUSTOM_WS_EVENTS', apiName: 'default/orders-updated',
                    condition: { conditionType: 'RESPONSE', criteria: { recipientId: myUserId } },
                    getEventData: true,
                },
            ],
        }));
    } else if (msg.type === 'REGISTER') {
        console.log(msg.response.validOnEvents, msg.response.invalidOnEvents);   // eventId per subscription
    } else if (msg.type === 'NOTIFICATION') {
        render(msg.response.eventId, msg.response.eventData);
    }
};
Key of an event Meaning
eventType INSTANCES, CUSTOM_APIS, SYSTEM_APIS or CUSTOM_WS_EVENTS.
apiName The API id for a table (SCHEMA_GET_ALL, GEN_POST_BULK_INSERT…), the name of the custom API, the id of the system API (ENCRYPT_DATA…), or the name of the custom WebSocket event.
instance, database, collection or table The table, for INSTANCES.
condition { conditionType: 'RESPONSE', criteria: { field: value } } : only the answers whose data has these values reach this client. For a custom event, the emitted data must match the criteria exactly, which is how one notification reaches one person.
select The fields of the data to send, { field: 1 }.
getEventData true sends the data ; false sends only the fact that it happened.
  • Subscriptions are checked against the groups of the API user and the auth provider of the event : invalidOnEvents says why one was refused.

3. Receive

{ "type": "NOTIFICATION", "response": { "eventId": "…", "eventData": [ { "order_no": 1001, "status": "PAID" } ] } }
  • eventId is the one of the register answer : it says which subscription the notification is for. eventData is there when the subscription asked for it with getEventData.
  • Notifications come from any server of the cluster : Redis passes them between the servers.
  • Unsubscribe with the eventId of the register answer :
ws.send(JSON.stringify({ objType: 'UNREGISTER', onEvents: [ eventId ] }));

When a notification is sent

  • After every successful call of a subscribed API : the answer of the call is the data, filtered by condition and select. The call can come over HTTP or from your code (custom APIs, hooks, events…).
  • Not when an error happens before the answer exists : a pre hook which throws sends nothing. A post hook which throws does not stop the notification.
  • For a custom WebSocket event, when your code emits it :
await g.sys.system.emitEventWS('default/orders-updated', { recipientId: '42', type: 'order-updated', order_no: 1001 });
await g.sys.system.emit_event_ws('default/orders-updated', {'recipientId': '42', 'type': 'order-updated', 'order_no': 1001})
$g->sys->system->emitEventWS('default/orders-updated', ['recipientId' => '42', 'type' => 'order-updated', 'order_no' => 1001]);
await g.Sys.System.EmitEventWS("default/orders-updated", new JsonObject { ["recipientId"] = "42", ["type"] = "order-updated", ["order_no"] = 1001 });
1
2
3
if _, err := g.Sys.System.EmitEventWS("default/orders-updated", am.M{"recipientId": "42", "type": "order-updated", "order_no": 1001}); err != nil {
    return nil, err
}
g.sys.system.emit_event_ws("default/orders-updated", json!({ "recipientId": "42", "type": "order-updated", "order_no": 1001 }))?;
g.sys.system.emitEventWS("default/orders-updated", Map.of("recipientId", "42", "type", "order-updated", "order_no", 1001));
g.sys.system.emitEventWS("default/orders-updated", mapOf("recipientId" to "42", "type" to "order-updated", "order_no" to 1001))
POST /api/system-api/admin/emit-event-ws
{ "name": "default/orders-updated", "eventData": { "recipientId": "42", "order_no": 1001 } }
  • To notify N people, emit once per person with their id in the data ; each client subscribes with its id in criteria.

Custom WebSocket events

APIs & Logic → WebSockets lists the event names of the account. A custom event needs to exist there before it can be subscribed to or emitted.

Field Meaning
Name The apiName of the subscription and the name given to emitEventWS.
Auth providers The provider whose token a client must have validated to subscribe. Empty : the first auth provider of the account.
Can user connect code A function which decides whether a client may subscribe : return { canConnect: true } or { canConnect: false, errorText: '…' }. It sees the tokens in g.req.auth.
Trigger on API Optional : an API whose success also emits this event.

Who may subscribe

A subscription is taken when all of these hold, and invalidOnEvents says which one did not :

  • The groups of the API user grant the event : every WebSocket event, or the ones the group names. That goes for the events of tables, of custom and system APIs, and for the custom WebSocket events of the account alike.
  • The client connected with a token of an auth provider of the event, when the account has auth providers.
  • The can user connect code of the event says yes, when the event has one.

What a client proved when it connected is looked at again while it stays connected :

What changes What the client sees
The API user is deactivated or removed, its password changes, or the account is deactivated. A TOKEN_VALIDATION message which says why, then the close code 1000 (Invalid client). Its subscriptions are gone.
A token the client connected with runs out : the token of the API user, or its person token. A TOKEN_VALIDATION message with the code 401 which names the token, then the close code 1000 (Invalid client). Its subscriptions are gone. See Staying connected.
The root user changes the secret the tokens of API users are signed with (root user settings). A TOKEN_VALIDATION message with the code 401 which names the token, then the close code 1000 (Invalid client), for every client which connected with a token of the secret before. Its subscriptions are gone. See Staying connected.
A group stops granting an event, or the API user leaves the group. { "type": "UNREGISTER", "response": { "eventId": "…" } } for each subscription which is taken back. The connection and the other subscriptions stay.
A group starts granting an event. Nothing : the client can subscribe to it from then on, without connecting again.
A custom WebSocket event is removed. { "type": "UNREGISTER", "response": { "eventId": "…" } } for its subscriptions.
  • A connection lasts as long as the first of its tokens : until the exp of the token of the API user and of the person token it connected with. A Google token counts five minutes longer, as its check does ; a token checked by the code of a custom provider counts until the exp that code answers, when it answers one. A token without an end closes nothing.

The connection

  • Ping : the server pings every client once a minute. A WebSocket client answers by itself (browsers and the usual libraries do, there is nothing to write), which keeps proxies from closing a quiet connection. A client may stay silent for hours ; one which answers nothing at all for two and a half minutes is taken for gone and closed.
  • Close codes : 1000 Invalid client when the tokens do not let the client in, or do not any more (one of them ran out, the API user changed) ; 1001 Server is going away when the server stops or restarts, which is no refusal.
  • Reconnect : subscriptions belong to the connection. When it closes, connect again and send REGISTER again ; after 1001 another server of the cluster takes the client at once. See Staying connected.
  • Size : a message of a client is at most 16 MiB (a REGISTER is a few hundred bytes). A longer one ends the connection.
  • A client which does not read : what it is told waits for it up to 64 MiB, and is skipped past that until it reads again. The connection stays.
  • Many clients : a client which waits costs a little memory and almost no CPU. How many a server holds, and what to look at before it holds them, is in Many clients.

Staying connected

A client stays connected for as long as it is reachable and its tokens are valid. It sends nothing for that : no request to repeat, no heartbeat of its own. The answers to the pings of the server are sent by the browser or the WebSocket library.

What closes a connection What the client sees What it does
One of its tokens runs out. A TOKEN_VALIDATION message with the code 401 which names the token, then the close code 1000. Gets a new token (with the refresh token, or a new login), connects again and sends REGISTER again.
The API user or the account is deactivated, or the password of the API user changes. A TOKEN_VALIDATION message which says why, then the close code 1000. The same, once it has a token which is valid again.
The root user changes the secret the tokens of API users are signed with. A TOKEN_VALIDATION message with the code 401 which names the token, then the close code 1000. Gets a new token, which is signed with the new secret, connects again and sends REGISTER again. A token of the secret before, and its refresh token, are not accepted any more : a new login.
The server stops or restarts. The close code 1001 (Server is going away). Connects again with the same tokens : another server of the cluster takes it at once.
Its machine or its network is gone : nothing came from it for two and a half minutes, not even the answers to the pings. Nothing : it is not there. Connects again when it is back.
A token ran out
{ "type": "TOKEN_VALIDATION", "errors": [ { "code": 401, "message": "The token of 'x-am-authorization' expired : connect again with a new token." } ] }
The secret of the tokens changed
{ "type": "TOKEN_VALIDATION", "errors": [ { "code": 401, "message": "The secret the token of 'x-am-authorization' is signed with changed : connect again with a new token." } ] }

One handler covers all five : when the socket closes, come back with a token which is valid at that moment, and subscribe again.

function connect() {
    getToken().then((token) => {                        // yours : a token which is valid now, renewed when it has to be
        const ws = new WebSocket('wss://ws.example.com/?x-am-authorization=' + encodeURIComponent(token));
        ws.onmessage = (e) => {
            const msg = JSON.parse(e.data);
            if (msg.type === 'CONNECTED') ws.send(JSON.stringify({ objType: 'REGISTER', onEvents: myEvents }));
            else if (msg.type === 'NOTIFICATION') render(msg.response.eventId, msg.response.eventData);
        };
        // Whatever closed it : come back, and subscribe again. Not all at the same moment : a second, and up to four more.
        ws.onclose = () => setTimeout(connect, 1000 + Math.random() * 4000);
    });
}
connect();
  • Wait a random moment before connecting again : when a server stops, every client it held sees the close at the same instant. Clients which all come back after exactly one second arrive together ; with a random wait they arrive spread over a few seconds, and each one is let in sooner. See Many clients.

  • How long a token lasts : expires_in of the token answer, 72 hours unless expiresInSeconds or the configuration say otherwise. The tokens of Google, Azure AD and AWS Cognito usually last an hour.

  • Nothing is sent again : what happens between the close and the new REGISTER does not reach the client afterwards. A client which may miss nothing opens a second connection with its new token a little before the old token runs out, sends REGISTER there, and closes the first one once the second answered.

Many clients

A client which waits costs memory, and almost nothing else : its socket, its tokens and its subscriptions. How many clients a server holds is a question of its memory, not of its CPU.

Server Clients connected at once Memory per client New clients a second A notification, p50
1 shared vCPU · 1 GB 26,484 8.6 KB 2,602 2.51 ms
2 shared vCPUs · 4 GB 338,016 8.5 KB 4,041 1.45 ms
4 shared vCPUs · 8 GB 801,972 8.7 KB 7,009 1.06 ms
8 shared vCPUs · 32 GB 3,662,564 8.8 KB 10,939 0.94 ms
2 dedicated vCPUs · 4 GB 338,006 8.5 KB 3,464 1.74 ms
4 dedicated vCPUs · 8 GB 795,888 8.7 KB 6,627 1.04 ms
8 dedicated vCPUs · 16 GB 1,732,035 8.8 KB 10,093 0.83 ms

Measured on 14 October 2026 with API Maker 4.0.0 on Linode servers set up by the install script, MongoDB and Redis on the same server. Every client connected with both tokens and one subscription ; « new clients a second » connect, subscribe and leave ; the notification is for one person, from the HTTP call which emits it to the client. As API servers which share one Redis : 2,022,026 clients on 1 × 8 vCPU · 16 GB, 1,915,960 clients on 2 × 4 vCPU · 8 GB, 1,619,372 clients on 4 × 2 vCPU · 4 GB. Every run, with its memory second by second : WebSocket benchmarks.

  • Count the servers from the memory : the clients one server holds, and one server more as a spare. Every server of a cluster takes clients, and all of them share the Redis of API Maker, which carries each notification to the server which holds the client : a load balancer needs no sticky session. Scale to N users has the picture and the rule.
  • Open files : every connected client is one open file of the server. The install script lets the service open as many as the firewall of the machine tracks connections : 256 for each MB of memory, 1,048,576 at the least (LimitNOFILE : about 4 million on a server of 16 GB) ; started any other way, API Maker raises its own limit as far as the system allows, and says in its log how many connections it takes. The WebSockets never take all of them : a sixteenth of the connections (64 at the least, 4,096 at the most) is kept for the calls of your APIs, which would else find no connection while the clients stay. A client which comes when the server holds as many WebSockets as it takes is answered 503 with Retry-After: 5 : it comes back, as after any close.
  • The connection table of the firewall : a Linux server which runs Docker tracks every connection (nf_conntrack), and a connection which closed stays in the table for a while. With its default size (262,144 connections) the table can fill before the memory does when many clients come and go, and the kernel then drops new connections without a word to anybody (nf_conntrack: table full, dropping packet in dmesg). The install script sizes the table from the memory of the server (/etc/sysctl.d/90-api-maker.conf), and API Maker warns in its log at start when the table is smaller than the clients the server could hold.
  • Clients which all come back together : after a restart, or when a server of a cluster stops, the clients it held connect again within the same seconds. API Maker lets 256 of them in at a time (the check of their tokens), handles the messages of 256 clients of an account at a time, and accepts no more than 1,024 connections ahead of the ones it is serving : the others wait for their turn as sockets, their request unread in the kernel, about 4 KB each. That is memory on top of what the clients take once they are in : a server with a quarter of its memory free has its clients back in seconds or minutes, one full to its last 5 % lets them back a few hundred at a time (8 GB and 800,000 clients) : the benchmarks restart a server with three quarters of the clients it holds. A client which waits a random moment before it connects again helps too (see Staying connected).
  • Clients which all go together : when a network or a load balancer goes, every client of a server leaves in the same second. Their sockets are closed at once, and Redis is told behind them.
  • A server which is killed : a server which stops takes its clients out of Redis first. One which is killed, or whose machine goes, cannot. So every server says every ten seconds that it is alive, and once the word of one of them ran out (45 seconds), another server takes out of Redis what it left, or the same one does when it comes back. Within a minute the calls of your APIs do not find its clients any more, and do not count them among the clients they told. The clients themselves connect again, as after any close.
  • Calls which come faster than the clients are told : an API answers before its clients are told, and a call takes far less than telling a thousand clients. Past 16,384 notifications which wait to be told, for each thread of the server, the next call whose answer is told to clients waits before it is answered, ten seconds at the most. Callers are slowed down to the pace their clients are told at : the memory of the server stays level, and a notification is not late by all the work which piled up before it.
  • The memory of the network : Linux gives all its TCP connections together a part of the memory of the machine (net.ipv4.tcp_mem, about 9 % as it sets it up). A connection which holds a short message nobody read yet, or one its client did not acknowledge yet, holds a page of it : nothing for a few thousand clients, all of it when hundreds of thousands of them do something in the same second, and Linux then slows every connection of the machine down. API Maker looks at its open connections once a second and raises that memory as they come, two pages for each connection : only upwards, never above a quarter of the memory of the machine (tcpMemoryPercent, 0 to leave the kernel alone), and not at all on a server with fewer connections than the kernel has room for. It says so in its log ; when it may not write the numbers (it is not root, or runs in a container), it says the sysctl line to run by hand.
  • Leave room : a server which is full of clients has no memory left for the work of its APIs. Keep a quarter of the memory free : the clients of a server which stops then fit on the others.

From your code

  • g.sys.system.emitEventWS(name, data) from any custom API, hook, event, scheduler or migration.
  • The sample custom API /default/ws-notify of a new account has the complete client snippet in its comments.
  • The system API does the same over HTTP.

Good to know

  • The admin panel and the local client use the same server with their own tokens : what they ask and what they are told (the files of the account) is for them only, an API user is refused it.
  • Groups grant WebSocket events : an API user subscribes to a custom event only when a group allows it.
  • The WebSocket is on the port of the APIs : at the address of BE_HOST_PORT, with ws:// for http:// and wss:// for https://. Behind Caddy, it is on the domain of the APIs.