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:
| Variable | Default | Description |
|---|---|---|
WEBHOOK_ENABLED | false | enables 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
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:
| Header | Value |
|---|---|
X-XLADA-Signature | HMAC-SHA256 of the request body, in hex |
X-XLADA-Timestamp | event time in ISO 8601 |
Content-Type | application/json |
User-Agent | XLADA |
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:
| Field | Event | Value |
|---|---|---|
notConnectedAfterHours | user.not_connected | how many hours the user has not connected |
expiration | user.expiration | hours relative to expireAt: negative â before expiry, positive â after |
For every other event meta is null.
Event listâ
| Scope | Events |
|---|---|
user | user.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_devices | user_hwid_devices.added, user_hwid_devices.deleted |
node | node.created, node.modified, node.disabled, node.enabled, node.deleted, node.connection_lost, node.connection_restored, node.traffic_notify |
service | service.panel_started, service.login_attempt_failed, service.login_attempt_success, service.subpage_config_changed, service.api_token_created, service.api_token_deleted |
errors | errors.bandwidth_usage_threshold_reached_max_notifications |
crm | crm.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_blocker | torrent_blocker.report |
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.
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â
- Notifications â Telegram, traffic thresholds, and reminders.
- Environment Variables â all panel switches.
- API Specification â data schemas for each event.
- Panel Security â how to protect the webhook receiver.