Webhooks

Webhooks allow your external systems to react to NoLag events. Configure webhooks to pre-populate state when actors subscribe, and trigger external workflows when messages are published. Webhook payloads include the actor's access scope for automatic multi-tenant isolation.

Key Concept

Webhooks are configured on the app. 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. Different topics can therefore call different endpoints, giving you fine-grained control over integrations.

Webhook Types

Hydration Webhook

Called when an actor subscribes to a topic. Use this to pre-populate the actor's state with current data (e.g., recent messages, current game state, latest prices).

Request Format

{
  "actorId": "01939f83-8b57-7c3e-a456-426614174000",
  "roomName": "general-chat",
  "topicName": "messages",
  "scope": {
    "accessScopeId": "01939f83-8b57-...",
    "slug": "client-acme",
    "name": "Acme Corp"
  }
}

scope is null for unscoped actors, or an object with the actor's access scope details for multi-tenant routing.

Response

Return any JSON body. The broker forwards it once per subscribe to the subscribing actor as a hydration frame, which the SDKs surface as the hydration event with the bare topic name and the body as data. It is delivered as its own event, not through 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:

client.on('hydration', ({ topic, data }) => {
  // topic is the bare topic name, e.g. 'messages'
  console.log('hydrated', topic, data)
})

const room = client.setApp(APP_SLUG).setRoom('general-chat')
room.subscribe('messages') // the hydration webhook fires for this subscribe

The hydration 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 described below is logged and dropped; the subscriber simply receives no hydration event, and nothing is written to the dead letter queue.

Trigger Webhook

Called when an actor publishes data to a topic. Use this to trigger external workflows, store messages, send notifications, or integrate with third-party services.

Request Format

{
  "roomName": "general-chat",
  "topicName": "messages",
  "actorId": "01939f83-8b57-7c3e-a456-426614174000",
  "data": {
    "text": "Hello everyone!",
    "timestamp": 1234567890
  },
  "scope": {
    "accessScopeId": "01939f83-8b57-...",
    "slug": "client-acme",
    "name": "Acme Corp"
  }
}

Response

Return any 2xx status code to acknowledge receipt. The response body is ignored. The call is asynchronous, so it never delays the publish or its delivery to subscribers.

Scope in Payloads

All webhook payloads include a scope field containing the actor's access scope information. This allows your backend to route requests to the correct tenant without additional lookups.

  • Scoped actors: scope contains accessScopeId, slug, and name
  • Unscoped actors: scope is null

Configuration

Via Dashboard

The dashboard edits the per-topic overrides:

  1. Navigate to your App's "Webhooks" page
  2. Click on a topic to expand its webhook settings
  3. Configure the On Subscribe (hydration) and/or On Publish (trigger) webhook URLs and headers
  4. Save changes for that topic

Via REST API

Patch the app with a project-scoped API key. hydrationWebhook and triggerWebhook are the app-wide defaults; topicConfigs holds the per-topic overrides. Each webhook is { "url": string, "headers"?: object }.

curl -X PATCH https://api.nolag.app/v1/apps/{appId} \
  -H "Authorization: Bearer nlg_live_xxx.secret" \
  -H "Content-Type: application/json" \
  -d '{
        "hydrationWebhook": {
          "url": "https://api.example.com/hydrate",
          "headers": { "Authorization": "Bearer xxx" }
        },
        "triggerWebhook": {
          "url": "https://api.example.com/trigger",
          "headers": { "Authorization": "Bearer xxx" }
        },
        "topicConfigs": {
          "messages": {
            "webhooks": {
              "onSubscribe": { "url": "https://api.example.com/hydrate-messages" },
              "onPublish": { "url": "https://api.example.com/trigger-messages" }
            }
          }
        }
      }'

The broker picks up configuration changes for a connection at its next authentication or periodic revalidation (about every 10 minutes), so a newly configured webhook may take that long to apply to clients that are already connected.

Authentication

Webhooks support two authentication methods:

  • Query Parameters - Add auth tokens directly in the URL: https://api.example.com/webhook?api_key=xxx
  • Request Headers - Add custom headers like Authorization or API key headers

Security Note

Always use HTTPS for webhook URLs. Webhook headers are stored as part of the app's configuration in plain form, not encrypted at rest, and a failed trigger call copies them into its dead letter entry. Use a dedicated, revocable secret for webhook authentication rather than a broadly privileged credential.

Retry Behavior

Every webhook call, hydration or trigger, is made the same way:

  • Up to 3 attempts in total: the initial call and two retries, waiting 1 second before the second attempt and 2 seconds before the third
  • Server errors (5xx) and connection errors trigger a retry
  • Client errors (4xx) do not; the call fails immediately
  • Each attempt times out after 30 seconds

Dead Letter Queue (DLQ)

When a trigger webhook fails after all attempts, the request is recorded in the Dead Letter Queue. Hydration failures are logged by the broker and dropped; they never reach the DLQ.

DLQ Entry Contents

Each DLQ entry contains:

  • Original webhook URL
  • Request headers and body (the same roomName, topicName, actorId, data and scope that were sent)
  • Response status code (if received)
  • Error message
  • Timestamp, retry count and a status of pending, failed or dead

Viewing the DLQ

The DLQ is exposed over the REST API only; there is no dashboard page for it yet. The endpoints are organization-scoped and authenticated with a user session token (the Bearer token the dashboard uses), not a project API key:

# List entries, optionally filtered by status (pending | failed | dead)
GET /v1/organizations/{orgId}/webhook-dlq?status=pending&limit=50&offset=0

# One entry
GET /v1/organizations/{orgId}/webhook-dlq/{dlqId}

# Mark an entry as permanently failed
DELETE /v1/organizations/{orgId}/webhook-dlq/{dlqId}/mark-dead

# Delete an entry
DELETE /v1/organizations/{orgId}/webhook-dlq/{dlqId}

# Count of pending entries, for monitoring
GET /v1/organizations/{orgId}/webhook-dlq/stats/pending-count

There is no retry endpoint. To replay a failed call, read the entry's request body and POST it to your endpoint yourself, then delete or mark the entry dead.

Best Practices

  • Respond quickly - Webhook handlers should respond within a few seconds. Use async processing for heavy work.
  • Idempotency - Design your handlers to be idempotent since retries may cause duplicate calls.
  • Use scope for tenant isolation - Use the scope field in webhook payloads to route data to the correct tenant.
  • Logging - Log incoming webhook requests for debugging and auditing.
  • Monitoring - Poll the DLQ pending count and alert when it grows; a failed trigger is otherwise silent.

Example: Chat Application

Here's a complete webhook handler example for a chat application. 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())

// Hydration: Return recent messages when user joins
app.post('/nolag/hydration', async (req, res) => {
  const { actorId, roomName, topicName, scope } = req.body

  // scope is null for unscoped actors, or:
  // { accessScopeId: "...", slug: "client-acme", name: "Acme Corp" }
  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 message and send notifications
app.post('/nolag/trigger', async (req, res) => {
  const { roomName, topicName, actorId, data, scope } = req.body

  await db.messages.create({
    data: {
      room: roomName,
      actorId,
      content: data.text,
      tenantId: scope?.accessScopeId
    }
  })

  await pushService.notifyRoom(roomName, data)
  res.status(200).send('OK')
})

Next Steps