Build
Realtime
Every table has an event stream at /v1/apps/{app}/tables/{table}/stream. It is plain server sent events (SSE): one long HTTP response, one event per row change. No SDK or websocket needed.
Events
Each event has a type, an id, and a JSON body:
id: 42
event: insert
data: {"id":"...","type":"insert","table":"messages","record":{...},"old_record":null,"at":"2026-09-22T18:00:00Z"}eventisinsert,update, ordelete.old_recordis set for updates and deletes.?events=insert,updatelimits which types you get.- A comment line is sent every 15 seconds as a heartbeat, so proxies keep the connection open.
- The stream follows the table's read policy. With an
ownertable, a user only hears about their own rows. - Up to 100 open streams per app.
Listen with curl
Make a table anyone with the publishable key may read, then listen in the background while a row goes in:
berth tables create --app "$APP" messages body:text:notnull --read publiccurl -sN --max-time 6 "https://api.atberth.com/v1/apps/$APP/tables/messages/stream?events=insert" \
-H "Authorization: Bearer $SECRET_KEY" > events.txt &
STREAM_PID=$!
sleep 2
curl -sS -o /dev/null -X POST https://api.atberth.com/v1/apps/$APP/tables/messages/rows \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"body":"hello over SSE"}'
wait $STREAM_PID || [ $? -eq 28 ]
grep -A 1 '^event: insert' events.txt--max-time only ends the demo; curl exits with 28 when it hits the limit. In real use, leave the connection open.
Listen from a browser
Browser EventSource cannot set headers, so the stream also takes the publishable key and user token as ?apikey= and ?access_token=. EventSource reconnects on its own and sends Last-Event-ID for you.
const base = "https://api.atberth.com/v1/apps/notes_app";
const params = new URLSearchParams({
apikey: "bpk_...",
access_token: session.access_token, // leave out for a public table
events: "insert,update,delete",
});
const stream = new EventSource(`${base}/tables/messages/stream?${params}`);
stream.addEventListener("insert", (e) => {
const { record } = JSON.parse(e.data);
addMessage(record);
});
stream.addEventListener("update", (e) => updateMessage(JSON.parse(e.data).record));
stream.addEventListener("delete", (e) => removeMessage(JSON.parse(e.data).old_record.id));
stream.onerror = () => console.log("stream dropped, EventSource will retry");The same query parameters work from a shell, which is a quick way to check a policy:
curl -sN --max-time 3 "https://api.atberth.com/v1/apps/$APP/tables/messages/stream?apikey=$PUBLISHABLE_KEY" || [ $? -eq 28 ]When the access token expires, refresh it and open a new EventSource with the new token.
Resume with Last-Event-ID
If a connection drops, send the last id you saw as a Last-Event-ID header and Berth replays what you missed before going live again.
LAST_ID=$(grep '^id:' events.txt | tail -n 1 | cut -d' ' -f2)
curl -sS -o /dev/null -X POST https://api.atberth.com/v1/apps/$APP/tables/messages/rows \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"body":"sent while you were away"}'
curl -sN --max-time 4 "https://api.atberth.com/v1/apps/$APP/tables/messages/stream" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Last-Event-ID: $LAST_ID" > replay.txt || [ $? -eq 28 ]
grep 'sent while you were away' replay.txtFrom the CLI
berth watch prints each event as it arrives until you press Ctrl+C:
berth watch --app "$APP" messages --events insert,update,deleteWhat is not included
Streams carry row changes only. There are no presence or broadcast channels. For chat style fan out, insert a row and let every client stream the table.