Guides

Backend integration

Publish from any language, process events in long-running workers without losing any, cache tokens, and run Thuum calls safely from your servers.

Your servers talk to Thuum in two ways:

  • REST for writes and reads: publish an event, set a state, read history. Any language with an HTTP client works.
  • WebSocket for long-running workers that react to events or state changes as they happen.

Publish from any language#

A token, then one request. Examples for the Event Broker; State Broker writes are the same with PUT / PATCH on /state/data/{topic}/{key}.

TOKEN=$(curl -s -X POST https://api.thuum.dev/api/acme/token \
  -H 'content-type: application/json' \
  -d "{\"key\":\"$THUUM_APP_KEY\"}" | jq -r .data.token)

curl -s -X POST https://api.thuum.dev/api/acme/event/data/invoices \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"type":"invoice.paid","data":{"invoice":"INV-2026-0042","amount":129.9}}'
const API = process.env.THUUM_API_URL;

async function publish(topic, type, data) {
	const token = await thuumToken(); // cached, see below
	const response = await fetch(`${API}/api/acme/event/data/${topic}`, {
		method: 'POST',
		headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
		body: JSON.stringify({ type, data })
	});
	if (!response.ok) throw new Error(`publish failed: ${response.status} ${await response.text()}`);
	return (await response.json()).data;
}

await publish('invoices', 'invoice.paid', { invoice: 'INV-2026-0042', amount: 129.9 });
import os, requests

API = os.environ["THUUM_API_URL"] + "/api/acme"

def publish(topic, type_, data, token):
    r = requests.post(
        f"{API}/event/data/{topic}",
        json={"type": type_, "data": data},
        headers={"authorization": f"Bearer {token}"},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["data"]
const event = await acme.eventBroker.data.publish('invoices', {
	type: 'invoice.paid',
	data: { invoice: 'INV-2026-0042', amount: 129.9 }
});

Cache tokens on servers#

Exchanging the key on every call works, but costs a round trip. Reuse a token until a few seconds before it expires, and retry once on 401:

JavaScript
let cached = null;

export async function thuumToken() {
	if (cached && cached.expiresAt - 5_000 > Date.now()) return cached.token;
	const response = await fetch(`${process.env.THUUM_API_URL}/api/acme/token`, {
		method: 'POST',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ key: process.env.THUUM_APP_KEY })
	});
	if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
	const { data } = await response.json();
	cached = { token: data.token, expiresAt: Date.now() + data.expires_in * 1000 };
	return cached.token;
}

export function forgetThuumToken() {
	cached = null;
}

Give backend keys a TTL that suits the cache (5 minutes, say); browser keys stay short.

Retries and duplicates#

  • Network errors and 5xx are worth retrying with backoff. 4xx are not, except 401 once after a fresh token.
  • A publish is not idempotent: if a request times out after the server stored the event, a retry stores it again. Put your own id in data ({ "invoice": "INV-2026-0042" }) so consumers can de-duplicate.
  • State writes (PUT, PATCH with the same patch, DELETE) are safe to retry: they end in the same value.

Workers that react to events#

A worker can subscribe over WebSocket and act on each event: send an email when an invoice is paid, update a search index when a document changes. Node.js 22+ has WebSocket built in.

Live delivery is best-effort, so a worker that must not miss events keeps a checkpoint — the ts of the last event it processed — and, on every (re)connection, subscribes and then reads the history since that checkpoint:

TypeScript
import { ThuumClient, type BrokerEvent } from '@thuum/client';

const thuum = new ThuumClient({ baseUrl: process.env.THUUM_API_URL! });
const TOPIC = 'invoices';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** One connection's life: subscribe, catch up from the checkpoint, process until it drops. */
async function run(): Promise<void> {
	const { token } = await thuum.exchangeToken('acme', process.env.THUUM_WORKER_KEY!); // invoices: S R
	const acme = thuum.domain('acme', token);
	const socket = acme.eventBroker.connect();

	const queue: BrokerEvent[] = [];
	socket.onMessage((event) => queue.push(event));
	try {
		await socket.ready;
		await socket.sub(TOPIC);

		// Catch up: everything since the checkpoint, oldest first, ahead of what arrives live.
		const since = await loadCheckpoint(); // epoch ms, from your database
		const missed = await acme.eventBroker.data.list(TOPIC, { since, limit: 1000 });
		queue.unshift(...missed.reverse());

		while (socket.open) {
			const event = queue.shift();
			if (!event) {
				await sleep(50);
				continue;
			}
			if (await alreadyProcessed(event.id)) continue; // idempotent handler
			await handle(event);
			await saveCheckpoint(event.id, event.ts);
		}
	} finally {
		socket.close();
	}
}

// Forever: after a failure or a disconnect, wait, then start again from the checkpoint.
for (;;) {
	await run().catch((e) => console.error('worker stopped:', e));
	await sleep(2_000 + Math.random() * 3_000);
}
  • The REST read uses the same token as the socket: it was just exchanged. For more than 1000 missed events, page with since / until.
  • When the connection drops, the loop ends and the next run() catches up from the checkpoint, so events still queued in memory are never needed.
  • since is inclusive: the last processed event comes back, and alreadyProcessed skips it.
  • A checkpoint older than the topic's retention window can't be fully recovered. Size retention for your worst outage, or have the producer also write to a durable queue your worker reads.

Workers that react to state changes#

Same idea with StateBrokerSocket: subscribe, then list the current states (or tell one key), and act on each change with a version newer than the one you last handled.

Security for servers#

  • One key per service, each with only its Main Topics and flags. A publisher doesn't need S or R.
  • Keys in a secret manager or environment variables; never in logs. A token in a URL (?token=) is a credential too.
  • Use GET /me in a startup check to fail fast when a key lacks a claim.