Webhooks
Webhooks let your own HTTP endpoints take part in NoLag traffic. A hydration webhook is called when an actor subscribes, so you can send it the current state. A trigger webhook is called when an actor publishes, so your systems can store or react to the message. Payloads include the actor's access scope for multi-tenant routing.
Where webhooks are configured. kraken reads webhook settings from the auth backend, per app. With @nolag/core they live on the app in the project document: hydrationWebhook and triggerWebhook set app-wide defaults, and topicConfigs.<topic>.webhooks.onSubscribe / onPublish override them for one topic. When both exist for a topic, the per-topic one is used. kraken's static auth.json backend has no webhook settings, so webhooks need @nolag/core or your own HTTP auth backend.
Hydration Webhook
Called when an actor subscribes to a topic. Use it to give the subscriber current data: recent messages, the state of a game, the latest prices.
Request
kraken sends a POST with a JSON body:
{
"actorId": "01939f83-8b57-7c3e-a456-426614174000",
"roomName": "general",
"topicName": "messages",
"scope": {
"accessScopeId": "01939f83-8b57-7c3e-a456-426614174002",
"slug": "acme",
"name": "Acme"
}
}
actorId: the subscribing actor's idroomName: the room slug from the addresstopicName: the bare topic namescope:nullfor an unscoped actor, otherwise the actor's access scope
Response
Return any JSON body. kraken forwards it to the subscribing connection, once per subscribe, as a hydration frame carrying the bare topic name and your body as data. The SDKs surface it as a hydration event, separate from the topic's message handlers, so your code can tell "state on join" from live traffic.
{
"recentMessages": [
{ "from": "alice", "text": "Hello!", "timestamp": 1234567890 },
{ "from": "bob", "text": "Hi there!", "timestamp": 1234567891 }
],
"participantCount": 5
}
Receive it in the SDK:
// Requires @nolag/js-sdk 1.14.0 or later; earlier versions drop the frame
client.on("hydration", (event: { topic: string; data: unknown }) => {
// event.topic is the bare topic name, e.g. 'messages'
console.log("hydrated", event.topic, event.data);
});
const room = client.setApp("chat").setRoom("general");
room.subscribe("messages"); // the hydration webhook fires for this subscribeThe call is asynchronous and best-effort: the subscribe succeeds immediately, and the hydration event arrives when your endpoint answers. A non-JSON body, a 4xx, or a failure after the retries below is logged by kraken and dropped; the subscriber simply receives no hydration event. Because the event names only the bare topic, a client subscribed to the same topic in several rooms cannot tell from the frame which room it is for.
Every subscribe calls the webhook, including the re-subscribe a client makes after reconnecting.
Trigger Webhook
Called when an actor publishes to a topic. Use it to store messages, send push notifications, or start a workflow in another system.
Request
{
"roomName": "general",
"topicName": "messages",
"actorId": "01939f83-8b57-7c3e-a456-426614174000",
"data": {
"text": "Hello everyone!",
"timestamp": 1234567890
},
"scope": null
}
data is the published payload exactly as the client sent it.
Response
Return any 2xx status to acknowledge it. The body is ignored. The call is asynchronous and fire-and-forget, so it never delays the publish or its delivery to subscribers, and kraken calls it for every accepted publish whether or not anyone is subscribed.
Scope in Payloads
Both payloads include a scope field, so your endpoint can route to the right tenant without a lookup:
- Scoped actors:
scopecontainsaccessScopeId,slugandname - Unscoped actors:
scopeisnull
Configuration
In the project document, each webhook is { "url": string, "headers"?: object }:
{
"slug": "chat",
"name": "Chat",
"topics": ["messages", "_typing"],
"hydrationWebhook": {
"url": "https://api.example.com/nolag/hydrate",
"headers": { "Authorization": "Bearer <webhook-secret>" }
},
"triggerWebhook": {
"url": "https://api.example.com/nolag/trigger",
"headers": { "Authorization": "Bearer <webhook-secret>" }
},
"topicConfigs": {
"messages": {
"webhooks": {
"onSubscribe": { "url": "https://api.example.com/nolag/hydrate-messages" },
"onPublish": { "url": "https://api.example.com/nolag/store-message" }
}
}
}
}
Here a subscribe to messages calls hydrate-messages and a publish to it calls store-message, while _typing falls back to the app-wide endpoints. To send no webhook for a topic, leave the app-wide fields unset and configure only the topics that need one.
kraken picks up a configuration change on a connection at its next authentication or at its periodic revalidation (about every 10 minutes), so a new webhook can take that long to reach clients that are already connected.
If you write your own HTTP auth backend instead, return the same settings on each app in the auth response as hydration_webhook, trigger_webhook and topic_webhooks (keyed by topic name, with on_subscribe and on_publish).
Authentication
Webhook requests are not signed in the current release, so your endpoint must authenticate them another way:
- Request headers: put a dedicated secret in
headers, for example anAuthorizationheader, and check it on every request - Query parameters: add a token to the URL, for example
https://api.example.com/nolag/trigger?key=...
Security note. Always use HTTPS for webhook URLs. Webhook headers are stored with the app's configuration as plain JSON in the core database, not encrypted. Use a dedicated, revocable secret for webhook authentication rather than a broadly privileged credential, and treat anyone who can read the project document or the database as able to call your endpoint.
Retry Behaviour
Every webhook call, hydration or trigger, is made the same way:
- Up to 3 attempts in total: the first call and two retries, waiting 1 second before the second attempt and 2 seconds before the third
- Server errors (5xx) and connection errors are retried
- Client errors (4xx) are not; the call fails immediately
- Each attempt times out after 30 seconds
Failed Trigger Calls
There is no dead letter queue. When a trigger call fails for good, kraken logs it and hands a failure report to its control backend:
- With the default control backend (
CONTROL_BACKEND=noop), the report is discarded and the call is gone. - With
CONTROL_BACKEND=http, kraken posts the report, including the request headers and body, to<CONTROL_HTTP_URL>/webhook-failures, where your own service can store it.@nolag/coredoes not provide that route. See Plugins.
A failed hydration call is only logged.
Best Practices
- Respond quickly: answer within a few seconds and do heavy work asynchronously
- Be idempotent: retries can deliver the same call more than once
- Use scope for tenant isolation: route on the
scopefield rather than trusting anything indata - Do not trust
datablindly: it is whatever the publishing client sent - Log and monitor: a failed trigger is otherwise silent
Example: Chat Application
A hydration handler that returns recent messages, and a trigger handler that stores each new one. The hydration handler's JSON reply is what subscribers receive in their hydration event.
import express from "express";
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.NOLAG_WEBHOOK_SECRET;
app.use("/nolag", (req, res, next) => {
if (req.headers.authorization !== `Bearer ${WEBHOOK_SECRET}`) {
return res.status(401).end();
}
next();
});
// Hydration: return recent messages when an actor subscribes
app.post("/nolag/hydrate", async (req, res) => {
const { roomName, scope } = req.body;
const tenantFilter = scope ? { tenantId: scope.accessScopeId } : {};
const messages = await db.messages.findMany({
where: { room: roomName, ...tenantFilter },
orderBy: { createdAt: "desc" },
take: 50,
});
res.json({ messages: messages.reverse() });
});
// Trigger: store each published message
app.post("/nolag/trigger", async (req, res) => {
const { roomName, actorId, data, scope } = req.body;
await db.messages.create({
data: { room: roomName, actorId, content: data.text, tenantId: scope?.accessScopeId },
});
res.status(200).send("OK");
});
app.listen(3000);Next Steps
- Quality of Service
- Access Control
- Access Scopes for the
scopeobject in payloads