---
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 per-topic 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 **per topic** within your app's topic settings. Different topics can trigger 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": "act_xxx",
  "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 data. This will be forwarded to the subscribing actor as a `hydration` message.

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

### 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": "act_xxx",
  "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.

## 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

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

```typescript [TypeScript]
// Webhooks are configured per topic through the NoLag Dashboard
// or via the REST API using topicConfigs:
//
// PATCH /v1/organizations/{orgId}/projects/{projId}/apps/{appId}
// {
//   "topicConfigs": {
//     "messages": {
//       "webhooks": {
//         "onSubscribe": {
//           "url": "https://api.example.com/hydrate",
//           "headers": { "Authorization": "Bearer xxx" }
//         },
//         "onPublish": {
//           "url": "https://api.example.com/trigger",
//           "headers": { "Authorization": "Bearer xxx" }
//         }
//       }
//     }
//   }
// }
```
```python [Python]
# Webhooks are configured per topic through the NoLag Dashboard
# or via the REST API using topicConfigs:
#
# PATCH /v1/organizations/{orgId}/projects/{projId}/apps/{appId}
# {
#   "topicConfigs": {
#     "messages": {
#       "webhooks": {
#         "onSubscribe": {
#           "url": "https://api.example.com/hydrate",
#           "headers": { "Authorization": "Bearer xxx" }
#         },
#         "onPublish": {
#           "url": "https://api.example.com/trigger",
#           "headers": { "Authorization": "Bearer xxx" }
#         }
#       }
#     }
#   }
# }
```
```go [Go]
// Webhooks are configured per topic through the NoLag Dashboard
// or via the REST API using topicConfigs:
//
// PATCH /v1/organizations/{orgId}/projects/{projId}/apps/{appId}
// {
//   "topicConfigs": {
//     "messages": {
//       "webhooks": {
//         "onSubscribe": {
//           "url": "https://api.example.com/hydrate",
//           "headers": { "Authorization": "Bearer xxx" }
//         },
//         "onPublish": {
//           "url": "https://api.example.com/trigger",
//           "headers": { "Authorization": "Bearer xxx" }
//         }
//       }
//     }
//   }
// }
```

## 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 encrypted at rest.

## Dead Letter Queue (DLQ)

When a webhook call fails after all retry attempts, the failed request is stored in the Dead Letter Queue. You can view and retry these failed requests from the dashboard.

### Retry Behavior

- Webhooks are retried up to **3 times** with exponential backoff
- Server errors (5xx) and network errors trigger retries
- Client errors (4xx) do not trigger retries
- Timeout is **30 seconds** per request

### DLQ Entry Contents

Each DLQ entry contains:

- Original webhook URL
- Request headers and body
- Response status code (if received)
- Error message
- Timestamp and retry count

### Viewing the DLQ

Failed webhook requests can be viewed and retried from the NoLag Dashboard. Navigate to your App settings and look for the Dead Letter Queue section.

## 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** - Monitor your DLQ and set up alerts for failed webhooks.

## Example: Chat Application

Here's a complete webhook handler example for a chat application:

```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")
}
```

[Quality of Service](/docs/concepts/qos)

[Access Control](/docs/concepts/acl)
