Skip to main content

Webhooks

Webhooks let the panel tell external systems about what happened on its own: a user was created, a node went down, traffic ran out. The panel sends a POST request with a JSON body to your address, so a script, a CRM, a monitoring system, or a chat bot can receive it.

Enabling​

Webhooks are off by default. Settings live in the panel .env:

VariableDefaultDescription
WEBHOOK_ENABLEDfalseenables webhook delivery
WEBHOOK_URL—recipient address; separate multiple addresses with commas
WEBHOOK_SECRET_HEADER—payload signature secret, exactly 64 [a-zA-Z0-9] characters
WEBHOOK_ENABLED=true
WEBHOOK_URL=https://example.com/xlada/webhook
WEBHOOK_SECRET_HEADER=vsmu67Kmg6R8FjIOF1WUY8LWBHie4scdEqrfsKmyf4IAf8dY3nFS0wwYHkhh6ZvQ
warning

With WEBHOOK_ENABLED=true, both variables are required. WEBHOOK_URL must start with http:// or https://, otherwise the container will not start. Keep the signature secret in .env only — the recipient uses it to verify that requests are authentic.

Which events to send is decided by the file /var/lib/remnawave/configs/notifications/notifications-config.yml inside the container. By default, all events on both channels are enabled in the image. An event that is missing from the file counts as disabled. To keep your edits, mount your own file over the /var/lib/remnawave/configs/notifications directory.

events:
user.created:
telegram: true
webhook: true
user.expired:
telegram: false
webhook: true
# additional address for this event only
additionalWebhookUrls:
- https://example.com/xlada/critical

Payload signature​

The panel sends service headers with every request:

HeaderValue
X-XLADA-SignatureHMAC-SHA256 of the request body, in hex
X-XLADA-Timestampevent time in ISO 8601
Content-Typeapplication/json
User-AgentXLADA

The signature is computed as HMAC-SHA256(secret, request_body) and compared against the header. Compare the raw body, not the parsed JSON.

import { createHmac, timingSafeEqual } from 'node:crypto';

function isValidSignature(rawBody, secret, signature) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}

Payload format​

The body always has four fields: scope, event, timestamp, and data. User events additionally have meta.

{
"scope": "service",
"event": "service.panel_started",
"timestamp": "2026-01-07T11:57:29.426Z",
"data": {
"panelVersion": "1.1.1"
}
}

The meta field is filled only for reminder events:

FieldEventValue
notConnectedAfterHoursuser.not_connectedhow many hours the user has not connected
expirationuser.expirationhours relative to expireAt: negative — before expiry, positive — after

For every other event meta is null.

Event list​

ScopeEvents
useruser.created, user.modified, user.deleted, user.revoked, user.disabled, user.enabled, user.limited, user.expired, user.traffic_reset, user.first_connected, user.bandwidth_usage_threshold_reached, user.not_connected, user.expiration
user_hwid_devicesuser_hwid_devices.added, user_hwid_devices.deleted
nodenode.created, node.modified, node.disabled, node.enabled, node.deleted, node.connection_lost, node.connection_restored, node.traffic_notify
serviceservice.panel_started, service.login_attempt_failed, service.login_attempt_success, service.subpage_config_changed, service.api_token_created, service.api_token_deleted
errorserrors.bandwidth_usage_threshold_reached_max_notifications
crmcrm.infra_billing_node_payment_in_7_days, crm.infra_billing_node_payment_in_48hrs, crm.infra_billing_node_payment_in_24hrs, crm.infra_billing_node_payment_due_today, crm.infra_billing_node_payment_overdue_24hrs, crm.infra_billing_node_payment_overdue_48hrs, crm.infra_billing_node_payment_overdue_7_days
torrent_blockertorrent_blocker.report
note

The user.not_connected and user.expiration events arrive only when NOT_CONNECTED_USERS_NOTIFICATIONS_ENABLED and EXPIRATION_NOTIFICATIONS_ENABLED are enabled. The user_hwid_devices.* events appear only when the device limit is enabled, and they go to webhooks only — they are not duplicated in Telegram.

Retries​

Delivery goes through a queue: a separate job is created for each address, with a limit of 100 concurrent sends. If the recipient does not respond, the request is retried 3 times with a 5-second pause — up to four attempts in total. After that, the error is written to the panel log, and the job stays in the queue with limited retention.

In notifications-config.yml you can set additional addresses for a specific event (additionalWebhookUrls). They are added to the addresses from WEBHOOK_URL rather than replacing them.

tip

Return 2xx to the webhook right away and move processing to the background. Until the recipient responds, the panel holds the connection open, and slow responses pile up the queue.

What's next​