---
title: Webhooks
description: Learn how to use NoLag webhooks for hydration and triggers to integrate with external systems.
---

# 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

```json [JSON]
{
  "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.

```json [JSON]
{
  "recentMessages": [
    { "from": "alice", "text": "Hello!", "timestamp": 1234567890 },
    { "from": "bob", "text": "Hi there!", "timestamp": 1234567891 }
  ],
  "participantCount": 5
}
```

Receive it in the SDK:

```typescript [TypeScript]
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
```
```python [Python]
def on_hydration(topic, data):
    # topic is the bare topic name, e.g. 'messages'
    print('hydrated', topic, data)

client.on('hydration', on_hydration)

room = client.set_app(APP_SLUG).set_room('general-chat')
await room.subscribe('messages')  # the hydration webhook fires for this subscribe
```
```go [Go]
client.On("hydration", func(args ...any) {
    topic := args[0].(string) // bare topic name, e.g. "messages"
    data := args[1]
    fmt.Println("hydrated", topic, data)
})

room := client.SetApp(appSlug).SetRoom("general-chat")
room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
    fmt.Println("live:", data)
})
```

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

```json [JSON]
{
  "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 }`.

```bash [Terminal]
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" }
            }
          }
        }
      }'
```
```typescript [TypeScript]
import { NoLagApi } from '@nolag/js-sdk'

const api = new NoLagApi(process.env.NOLAG_API_KEY) // nlg_live_...

// The typed apps.update() covers name, slug, description and config;
// send webhook fields through the generic request helper.
await api.request('PATCH', `/apps/${appId}`, {
  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:

```bash [Terminal]
# 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:

```typescript [Express.js]
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')
})
```
```python [FastAPI]
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class ScopeInfo(BaseModel):
    accessScopeId: str
    slug: str
    name: str

class HydrationRequest(BaseModel):
    actorId: str
    roomName: str
    topicName: str
    scope: Optional[ScopeInfo] = None

class TriggerRequest(BaseModel):
    roomName: str
    topicName: str
    actorId: str
    data: dict
    scope: Optional[ScopeInfo] = None

# Hydration: Return recent messages when user joins
@app.post('/nolag/hydration')
async def hydration(req: HydrationRequest):
    filters = {'room': req.roomName}
    if req.scope:
        filters['tenant_id'] = req.scope.accessScopeId

    messages = await db.messages.find_many(
        where=filters,
        order_by={'created_at': 'desc'},
        take=50
    )
    return {'messages': list(reversed(messages))}

# Trigger: Store message and send notifications
@app.post('/nolag/trigger')
async def trigger(req: TriggerRequest):
    await db.messages.create(
        data={
            'room': req.roomName,
            'actor_id': req.actorId,
            'content': req.data['text'],
            'tenant_id': req.scope.accessScopeId if req.scope else None
        }
    )
    await push_service.notify_room(req.roomName, req.data)
    return {'status': 'ok'}
```
```go [Gin]
package main

import "github.com/gin-gonic/gin"

type ScopeInfo struct {
    AccessScopeID string `json:"accessScopeId"`
    Slug          string `json:"slug"`
    Name          string `json:"name"`
}

type HydrationRequest struct {
    ActorID   string     `json:"actorId"`
    RoomName  string     `json:"roomName"`
    TopicName string     `json:"topicName"`
    Scope     *ScopeInfo `json:"scope"`
}

type TriggerRequest struct {
    RoomName  string                 `json:"roomName"`
    TopicName string                 `json:"topicName"`
    ActorID   string                 `json:"actorId"`
    Data      map[string]interface{} `json:"data"`
    Scope     *ScopeInfo             `json:"scope"`
}

func main() {
    r := gin.Default()

    // Hydration: Return recent messages
    r.POST("/nolag/hydration", func(c *gin.Context) {
        var req HydrationRequest
        c.BindJSON(&req)

        tenantID := ""
        if req.Scope != nil {
            tenantID = req.Scope.AccessScopeID
        }

        messages := db.GetRecentMessages(req.RoomName, 50, tenantID)
        c.JSON(200, gin.H{"messages": messages})
    })

    // Trigger: Store message and notify
    r.POST("/nolag/trigger", func(c *gin.Context) {
        var req TriggerRequest
        c.BindJSON(&req)

        db.CreateMessage(req.RoomName, req.ActorID, req.Data, req.Scope)
        pushService.NotifyRoom(req.RoomName, req.Data)
        c.String(200, "OK")
    })

    r.Run(":8080")
}
```

## Next Steps

- [Quality of Service](/docs/concepts/qos)
- [Access Control](/docs/concepts/acl)
- [Access Scopes](/docs/scopes) for the `scope` object in payloads
