Skip to content
Console

SSE connection

Real-time data is pushed with Server-Sent Events (SSE): the client sends one GET, and the server keeps the connection open and sends events. One connection can subscribe to several data streams (topics) at once. Every data message is gzip-compressed and then base64-encoded. Decode each message on the client.

Topic and single-topic path Contents Permission License
trem.rts.v1
/api/v1/trem/sse/rts
TREM station snapshot (JSON), 2 messages per second trem.rts Non-commercial
trem.intensity.v1
/api/v1/trem/sse/intensity
TREM intensity report (XML) trem.intensity Non-commercial
eq.report.v1
/api/v1/trem/sse/report
Central Weather Administration (CWA) earthquake report (JSON) eq.report Commercial OK

TREM data are unofficial observations; the Central Weather Administration's releases always prevail. Under the Meteorological Act, they must not be used to predict earthquakes or to issue earthquake warnings. Read the full notice

The .v1 at the end of a topic name is the data-format version. The /v1 in the path is the version of the SSE connection protocol (the greeting, event names, compression, and heartbeat).

  • Depends on the topics you subscribe to: data from each topic follows its own class and attribution.

Where to show it, examples and commercial-use requests: Data licensing and attribution.

The three topics under Available topics are public topics. You can connect without a token, and the token’s permissions and subscription are not checked.

Ways to send a token (use one):

  • Header Authorization: Bearer <token> (preferred). The prefix must be exactly Bearer (case-sensitive). Any other format is ignored.
  • Query token=<token>. The query is read only when the header did not yield a token.

SSE does not read the X-Api-Token header. How to get a token is in API Token.

What changes when you send a token:

Case Result
No token Messages flow normally, and nothing is counted on an account. The multiplexed entry’s info includes "anonymous": true
A valid token Messages flow normally, and usage is counted on that account: 1 count per topic per second the connection stays up, recorded under that topic’s permission name (resource). Going over the quota does not cut off a public topic
An invalid, expired, or revoked token The whole request returns 401 invalid token, and public topics are not pushed either. Omit the token when you do not need metering
A token was sent, but the auth service is temporarily unavailable Public topics are still pushed, treated as unauthenticated ("anonymous": true), and not metered

How usage and quota are counted is in Usage and quota.

A topic that requires a permission is checked for the token’s permission, the account’s subscription, and the quota, in that order. Reason strings for a failure are in Disconnect and error reasons. On the multiplexed entry, the response is 200 as soon as one topic passes. The ones that did not pass are listed in denied on info.

Use the load-balancer entry https://api.lb.exptech.dev as the Base URL (you can also pin https://api.lb-tpe1.exptech.dev or https://api.lb-khh1.exptech.dev). There are two entries:

  • Multiplexed entry /api/v1/trem/sse?topics=...: one connection receives several topics. Every data message has event: <topic name>, and you split on the event name. Use this for new programs.
  • Single-topic path (see the table above): one topic only. Data events have no event: line. A browser EventSource receives them with onmessage.
GEThttps://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1
Query parameters
  • topicsstringRequired

    Topics to subscribe to, separated by commas. You can also repeat the parameter (?topics=a&topics=b). Names are case-sensitive and must match exactly. Leading and trailing spaces are trimmed, and duplicates are merged. Required only on the multiplexed entry.

  • modestringOptionalDefault sleep

    live (case-insensitive) delivers every trem.rts.v1 snapshot. Omitting it, or any other value, is sleep mode. See Modes.

  • tokenstringOptional

    API Token. Only for clients that cannot set headers (for example a browser EventSource). Otherwise use the Authorization header.

Request
curl -N "https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1"
Response

Connected. The server starts pushing text/event-stream

retry: 3000
event: info
data: {"location":"lb-tpe1","topics":["trem.intensity.v1","eq.report.v1","trem.rts.v1"],"anonymous":true}
event: trem.rts.v1
data: H4sIAAAAAAAC/02OywrDIBRE...
: ping 1790619700

topics is missing, or a name is wrong

unknown topic: trem.rts

The token is invalid, expired, or revoked

invalid token
  • Only GET is accepted. Other methods return 405 (body method not allowed).
  • Response headers on success: Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no.
  • Cross-origin (CORS): every response sends Access-Control-Allow-Origin: *, and the preflight allows the Authorization header, so a browser can connect with fetch() and that header.
  • When several topics on one multiplexed connection have data at the same time, they are sent in this order: trem.intensity.v1, eq.report.v1, trem.rts.v1.

mode affects only trem.rts.v1, and it applies to the whole connection:

mode trem.rts.v1 trem.intensity.v1, eq.report.v1
sleep (default) Sends only snapshots where at least one station has alert. When no station is triggered, this topic has no data at all (only heartbeats) Every message is sent
live Every snapshot is sent (2 per second) Every message is sent

A snapshot sent in sleep mode has the same contents as in live mode. It still includes every station. Snapshots with no triggered station are simply not sent.

To change mode, reconnect. Open a second connection in the new mode first, wait for event: info, then close the old connection, so nothing is missed in between.

After the connection succeeds, the server sends, in order:

retry: 3000
event: info
data: {"location":"lb-tpe1","topics":["trem.intensity.v1","eq.report.v1","trem.rts.v1"],"anonymous":true}
event: trem.rts.v1
data: H4sIAAAAAAAC/02OywrDIBRE/+Wub8XrIzHumob+RMhCgpRAia3aVci/VyF9zGrgHIbZIIVXnD1YmEP0p7yuBAg5gaW24w11jeElCCm7vIS1gA2IWiP0pdYFLGcFP26utoZ2LHi4Dv0Pqy82LYK7+5jLfBXPmvdkDlEyeYiKcf0nFtM/wY6jkKxTSIKYFigFfi4qPU37G7yO1gbLAAAA
: ping 1790619700
Event data Compressed Notes
(retry: 3000) None No Suggested reconnect interval, 3000 milliseconds. EventSource applies it automatically
info JSON No The first event after a successful connection. Fields are in the table below
Topic name (multiplexed entry) / no event: line (single-topic path) base64 string Yes The data. See Decode data
unsubscribed JSON {"topic":"...","reason":"..."} No One topic on a multiplexed connection was stopped. The others continue
close Plain-text reason No The server is about to close this connection
: ping <Unix seconds> (comment line, no data) No Heartbeat. See Heartbeat

Fields of info:

FieldTypeDescription
locationstringName of the server node serving this connection, for example lb-tpe1.
topicsstring[]Topics actually subscribed, in push priority order (not the request order). Present only on the multiplexed entry.
deniedobjectTopics that were not subscribed, mapped to the reason. Omitted when every topic was subscribed. Present only on the multiplexed entry.
anonymousbooleantrue when this connection is not tied to an account (not metered). Otherwise omitted. Present only on the multiplexed entry.

Events have no id: field, and the server does not read Last-Event-ID. A reconnect does not resume from the break. Whether each topic replays anything on connect is in Reconnect.

Every 60 seconds the server sends one comment line:

: ping 1790619700
  • The number is the server time (Unix seconds, corrected with NTP).
  • A multiplexed connection sends this once every 60 seconds, however many topics it has.
  • In sleep mode, when there is no data, the heartbeat is the only traffic. Use it to tell whether the connection is still alive: if no bytes arrive for 90 seconds, treat it as disconnected and reconnect.
  • EventSource ignores comment lines, so the program cannot see the heartbeat. When you parse the stream yourself, do not treat it as data.

data on each data event is: original contents → gzip (each message is one complete gzip member) → standard base64 (with = padding). The response has no Content-Encoding: gzip, so the HTTP layer does not decompress it. Decode each message yourself. info, unsubscribed, and close are not compressed.

After decoding, trem.rts.v1 and eq.report.v1 are JSON, and trem.intensity.v1 is XML. All of them are UTF-8.

Browser (using DecompressionStream):

async function decodeData(b64) {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}

Node.js:

import { gunzipSync } from "node:zlib";
function decodeData(b64) {
return gunzipSync(Buffer.from(b64, "base64")).toString("utf8");
}

Python:

import base64
import gzip
def decode_data(b64: str) -> str:
return gzip.decompress(base64.b64decode(b64)).decode("utf-8")

The trem.rts.v1 message in the stream format example above decodes to:

{"source":"core-tnn1","ts":1790619680000,"stations":{"117825C":{"i":0.0,"pga":0.61},"11DFDBC":{"i":0.4,"pga":0.87,"alert":1},"1A50B18":{"i":3.3,"pga":4.05,"alert":1}},"eq":[[23.94,121.52,32,1790619645]]}
Case What to do
The network drops, or the server closes the connection (no event: close) Wait 3 seconds, then reconnect. On repeated failures, double the wait (3, 6, 12… up to 60 seconds) and add a random delay. After you receive event: info, reset the wait to 3 seconds
No bytes for 90 seconds Close the connection yourself and reconnect
HTTP 400 The request itself is wrong (topic name, or topics missing). Do not retry until you fix it
HTTP 401 invalid token The token is invalid or revoked. Do not retry with the same token
HTTP 429 Wait at least 60 seconds before trying again
HTTP 503 A temporary error. Retry with the backoff above
event: close, data: token revoked Stop reconnecting. Use a new token

EventSource reconnects automatically on a network drop, using retry: 3000. When the server returns a status other than 200, it stops (readyState becomes CLOSED), and you have to create a new one. After event: close, call close(). Otherwise EventSource reconnects on its own.

Data from the gap after you reconnect:

  • trem.rts.v1: not replayed. Each message is a full snapshot. Keep processing the next one.
  • trem.intensity.v1: on connect, the server first replays the latest report from the last 240 seconds for each intensity episode. It can duplicate one you received before the drop. Deduplicate with EventID + Serial.
  • eq.report.v1: not replayed. After you reconnect, call GET /api/v2/eq/report to fill the gap.

The server does not guarantee that every message is delivered. If the connection cannot keep up, the server drops the oldest data in that connection’s queue (trem.rts.v1 keeps only the latest message). If one write takes more than 30 seconds and still cannot be sent, the connection is dropped, with no event: close.

When the connection fails to open, the server returns a normal HTTP status. The body is plain text (with a trailing newline):

Status Body (exact text) When
400 topics required, e.g. ?topics=... The multiplexed entry was called with no topic
400 unknown topic: <name> The topic name is wrong (case-sensitive)
401 invalid token An invalid, expired, or revoked token was sent (whatever topics were requested)
401 unauthorized A topic that requires a permission was requested with no token
403 forbidden: token scope missing The token does not have that topic’s permission
403 forbidden: not subscribed The account is not subscribed to that resource
429 quota exceeded The account is over quota for that resource. A reconnect within about 60 seconds is still 429
503 auth service unavailable The auth service is temporarily unavailable, and the request included a topic that requires a permission
405 method not allowed The method is not GET

On the multiplexed entry, when every requested topic is rejected, the body of 401, 403, 429, and 503 appends : <topic name>, for example forbidden: not subscribed: <topic name>. If any topic passes, the response is 200. Rejected topics are in info.denied, and the reason is one of unauthorized, forbidden: token scope missing, forbidden: not subscribed, or quota exceeded (no topic name appended).

After the connection is open, the server may send:

Event data (exact text) When
unsubscribed {"topic":"<topic name>","reason":"quota exceeded"} A topic that requires a permission went over quota. Only that topic stops. The others continue
close quota exceeded The last topic was stopped for going over quota, and the connection closes
close token revoked The token was revoked. About 5 seconds later, every SSE connection for that account closes (including public topics). Reconnecting with the same token returns 401 invalid token
(no event) — Network drop, write timeout (30 seconds), or the server restarted

A public topic that goes over quota does not receive unsubscribed or close.

Two complete clients you can run as they are. They show decoding, a heartbeat timeout, reconnect with backoff, and de-duplication.

Node.js 18 or later (built-in fetch). No packages to install. Subscribes to all three topics, handles a heartbeat timeout, reconnects with backoff, and deduplicates. After connecting, it first catches up on recent earthquake reports.

// sse-client.mjs — run: node sse-client.mjs
// Environment: MODE=live receives every RTS snapshot; EXPTECH_TOKEN=<token> is optional, and sending it counts usage on the account
import { gunzipSync } from "node:zlib";
const BASE = process.env.BASE ?? "https://api.lb.exptech.dev";
const REPORT_API = process.env.REPORT_API ?? "https://api.core.exptech.dev";
const TOPICS = ["trem.intensity.v1", "eq.report.v1", "trem.rts.v1"];
const MODE = process.env.MODE === "live" ? "live" : "sleep";
const TOKEN = process.env.EXPTECH_TOKEN ?? "";
const STALE_MS = 90_000; // Heartbeat is every 60 seconds. Reconnect if nothing arrives for 90 seconds
const MIN_RETRY_MS = 3_000;
const MAX_RETRY_MS = 60_000;
class FatalError extends Error {} // An error that retrying will not fix
let retryMs = MIN_RETRY_MS;
const intensitySerial = new Map(); // intensity-report EventID -> latest Serial already handled
const seenReports = new Set(); // earthquake report IDs already handled
function decodeData(b64) {
return gunzipSync(Buffer.from(b64, "base64")).toString("utf8");
}
function onRts(rts) {
const ids = Object.keys(rts.stations);
const alerted = ids.filter((id) => "alert" in rts.stations[id]);
console.log(`[rts] ${new Date(rts.ts).toISOString()} stations ${ids.length}, alerted ${alerted.length}, earthquakes ${rts.eq.length}`);
}
function onIntensity(xml) {
const eventId = xml.match(/<Head>[\s\S]*?<EventID>(\d+)<\/EventID>/)?.[1];
const serial = Number(xml.match(/<Serial>(\d+)<\/Serial>/)?.[1]);
if (!eventId || serial <= (intensitySerial.get(eventId) ?? 0)) return; // replay or duplicate
intensitySerial.set(eventId, serial);
const maxInt = xml.match(/<MaxInt>([^<]*)<\/MaxInt>/)?.[1];
console.log(`[intensity] ${eventId} report ${serial}, max intensity ${maxInt}`);
}
function onReport({ reason, data }) {
if (reason === 3 && Date.now() - data.time > 86_400_000) return; // backfilled older report
const isNew = !seenReports.has(data.id);
seenReports.add(data.id);
console.log(`[report] ${isNew ? "new report" : "update"} ${data.id} M${data.mag} depth ${data.depth} km (reason ${reason})`);
}
async function catchUpReports() {
// eq.report.v1 does not replay. After connecting, catch up on recent reports with the HTTP API
const res = await fetch(`${REPORT_API}/api/v2/eq/report?limit=5&minIntensity=0`);
if (!res.ok) return console.warn(`failed to catch up earthquake reports: HTTP ${res.status}`);
for (const r of await res.json()) {
if (!seenReports.has(r.id)) console.log(`[report] caught up ${r.id} M${r.mag}`);
seenReports.add(r.id);
}
}
function handleBlock(block) {
let event = "message";
const data = [];
for (const line of block.split("\n")) {
if (line.startsWith(":")) continue; // heartbeat `: ping <seconds>`
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
}
if (data.length === 0) return; // `retry: 3000` or a heartbeat
const text = data.join("\n");
switch (event) {
case "info":
retryMs = MIN_RETRY_MS;
console.log("connected", text);
catchUpReports().catch((err) => console.warn("failed to catch up earthquake reports:", err.message));
break;
case "unsubscribed":
console.warn("topic stopped:", text);
break;
case "close":
if (text === "token revoked") throw new FatalError("token was revoked; use a different token");
throw new Error(`server closed the connection: ${text}`);
case "trem.rts.v1":
onRts(JSON.parse(decodeData(text)));
break;
case "trem.intensity.v1":
onIntensity(decodeData(text));
break;
case "eq.report.v1":
onReport(JSON.parse(decodeData(text)));
break;
}
}
async function connect() {
const url = new URL("/api/v1/trem/sse", BASE);
url.searchParams.set("topics", TOPICS.join(","));
if (MODE === "live") url.searchParams.set("mode", "live");
const headers = { Accept: "text/event-stream" };
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
const ac = new AbortController();
let timer = setTimeout(() => ac.abort(), STALE_MS);
try {
const res = await fetch(url, { headers, signal: ac.signal });
if (!res.ok) {
const body = (await res.text()).trim();
if ([400, 401, 403, 405].includes(res.status)) throw new FatalError(`HTTP ${res.status} ${body}`);
throw new Error(`HTTP ${res.status} ${body}`);
}
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of res.body) {
clearTimeout(timer);
timer = setTimeout(() => ac.abort(), STALE_MS);
buf += decoder.decode(chunk, { stream: true }).replace(/\r/g, "");
let end;
while ((end = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, end);
buf = buf.slice(end + 2);
handleBlock(block);
}
}
} finally {
clearTimeout(timer);
ac.abort();
}
}
for (;;) {
try {
await connect();
console.warn("connection ended");
} catch (err) {
if (err instanceof FatalError) {
console.error(err.message);
process.exit(1);
}
console.warn("connection dropped:", err.name === "AbortError" ? "no data for 90 seconds" : err.message);
}
const wait = retryMs + Math.random() * 1000;
retryMs = Math.min(retryMs * 2, MAX_RETRY_MS);
console.log(`reconnecting in ${Math.round(wait / 1000)} seconds`);
await new Promise((resolve) => setTimeout(resolve, wait));
}

Subscribe to public topics with EventSource (no token). Decoding is asynchronous, so a Promise queue keeps handling in order.

<!doctype html>
<meta charset="utf-8" />
<title>ExpTech SSE</title>
<pre id="log"></pre>
<script type="module">
const URL_ = "https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1";
const log = (msg) => (document.getElementById("log").textContent = `${msg}\n` + document.getElementById("log").textContent);
async function decodeData(b64) {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}
// Handle messages in arrival order (decodeData is asynchronous)
let queue = Promise.resolve();
const inOrder = (fn) => (event) => {
queue = queue.then(() => fn(event)).catch((err) => console.error(err));
};
const intensitySerial = new Map();
let es;
let retryMs = 3000;
function connect() {
es = new EventSource(URL_);
es.addEventListener("info", (e) => {
retryMs = 3000;
log(`connected ${e.data}`);
});
es.addEventListener("trem.rts.v1", inOrder(async (e) => {
const rts = JSON.parse(await decodeData(e.data));
const alerted = Object.values(rts.stations).filter((s) => "alert" in s).length;
log(`RTS ${new Date(rts.ts).toLocaleTimeString()} alerted stations ${alerted}`);
}));
es.addEventListener("trem.intensity.v1", inOrder(async (e) => {
const doc = new DOMParser().parseFromString(await decodeData(e.data), "application/xml");
const eventId = doc.querySelector("Head > EventID").textContent;
const serial = Number(doc.querySelector("Head > Serial").textContent);
if (serial <= (intensitySerial.get(eventId) ?? 0)) return; // replay or duplicate
intensitySerial.set(eventId, serial);
log(`intensity report ${eventId} report ${serial} max intensity ${doc.querySelector("MaxInt").textContent}`);
}));
es.addEventListener("eq.report.v1", inOrder(async (e) => {
const { reason, data } = JSON.parse(await decodeData(e.data));
log(`earthquake report ${data.id} M${data.mag} (reason ${reason})`);
}));
es.addEventListener("close", (e) => {
log(`server closed the connection: ${e.data}`);
es.close(); // without close(), EventSource reconnects on its own
if (e.data !== "token revoked") reconnectLater();
});
es.onerror = () => {
// On a network drop, readyState is CONNECTING and EventSource reconnects itself.
// When the server returns an error status, readyState is CLOSED and you create a new one.
if (es.readyState === EventSource.CLOSED) reconnectLater();
};
}
function reconnectLater() {
const wait = retryMs + Math.random() * 1000;
retryMs = Math.min(retryMs * 2, 60_000);
setTimeout(connect, wait);
}
connect();
</script>
  • To receive several topics, combine them on the multiplexed entry as one connection. Usage is counted the same as separate connections, and you only authenticate once and hold one connection.
  • trem.rts.v1 in live mode is 2 messages per second, and each one includes every station. It is the highest-traffic topic. Use the default sleep mode when you only need data during an earthquake.
  • Data formats for each topic are in trem.rts.v1, trem.intensity.v1, and eq.report.v1.