Event Types
The 12 webhook actions Marzban emits, their payloads, and how to consume them type-safely.
Marzban emits a webhook for each of 12 user-lifecycle events. Every payload shares a common base and adds a few event-specific fields.
Payload shape
Every webhook is a JSON object with these base fields:
| Field | Type | Description |
|---|---|---|
action | | Which event fired — one of the 12 names below |
username | string | Username of the affected user |
enqueued_at | number | Unix timestamp (float) when the event was queued |
send_at | number | Unix timestamp (float) when the event was sent |
tries | number | Number of delivery attempts |
Most events also carry the full under user, and admin-initiated ones add the acting under by. A complete user_created payload:
{
"action": "user_created",
"username": "alice",
"enqueued_at": 1705312800.0,
"send_at": 1705312800.1,
"tries": 0,
"user": {
"username": "alice",
"status": "active",
"data_limit": 10737418240,
"expire": 1707904800,
"subscription_url": "https://vpn.example.com/sub/abc123"
},
"by": {
"username": "admin",
"is_sudo": true
}
}Events reference
All 12 actions, the fields each adds on top of the base, and when they fire:
| Action | Extra fields | Fires when |
|---|---|---|
user_created | user, by | A new user was created |
user_updated | user, by | A user's profile was modified |
user_deleted | by | A user was deleted (no user — it's gone) |
user_enabled | user, by? | A user was re-enabled (by is null for an automated re-enable) |
user_disabled | user, by, reason? | A user was disabled |
user_limited | user | A user hit their data limit |
user_expired | user | A user's subscription expired |
data_usage_reset | user, by | A user's data usage was reset manually |
data_reset_by_next | user | Data was reset by the "next plan" mechanism |
subscription_revoked | user, by | A subscription token was revoked and reissued |
reached_usage_percent | user, used_percent | A user crossed a configured usage-percent threshold |
reached_days_left | user, days_left | A user crossed a configured days-left threshold |
Payload examples
user_created is shown above. Two events add fields worth seeing in full:
user_disabled — adds reason
{
"action": "user_disabled",
"username": "alice",
"enqueued_at": 1705312900.0,
"send_at": 1705312900.1,
"tries": 0,
"user": { "username": "alice", "status": "disabled" },
"by": { "username": "admin", "is_sudo": true },
"reason": "Suspicious activity"
}reached_usage_percent — adds a numeric threshold field
{
"action": "reached_usage_percent",
"username": "alice",
"enqueued_at": 1705313000.0,
"send_at": 1705313000.0,
"tries": 0,
"user": { "username": "alice", "used_traffic": 9663676416 },
"used_percent": 90.0
}reached_days_left has the same shape, with days_left (a number) in place of used_percent.
Working with events in TypeScript
The SDK ships three things for handling events safely: the union for narrowing, the constant for action names, and the validator for untrusted input.
Narrowing the union
Every payload is a member of the discriminated union, keyed by action. Switch on it and TypeScript unlocks exactly the event-specific fields that event carries:
import type { WebhookType } from 'marzban-sdk'
function handleEvent(payload: WebhookType) {
switch (payload.action) {
case 'user_created':
// payload.user and payload.by are available here
console.log('New user:', payload.user.username)
break
case 'reached_usage_percent':
// payload.used_percent is available here
console.log(`${payload.username} used ${payload.used_percent}%`)
break
case 'user_deleted':
// payload.user is NOT available — the user is gone
console.log('Deleted:', payload.username)
break
}
}Action constants
Rather than hard-code action strings, import the constant (every name) and the type (their union):
import { ACTIONS, type WebhookAction } from 'marzban-sdk'
// Subscribe without magic strings — ACTIONS is fully autocompleted
sdk.webhook.on(ACTIONS.user_created, payload => {
console.log('New user:', payload.user.username)
})
// Iterate every action, e.g. to attach one handler to all of them
for (const action of Object.values(ACTIONS)) {
sdk.webhook.on(action, payload => log(payload.action))
}
// Annotate your own helpers
function describe(action: WebhookAction): string {
return action.replace(/_/g, ' ')
}.user_created === 'user_created', so the constants are fully interchangeable with the raw strings — they only add autocomplete and a single source of truth.
Schema validation
Need to validate an untrusted payload yourself? The matching Zod schemas are exported too. sdk.webhook.parseWebhook already runs for you (and verifies the signature), so reach for these only when you parse events outside the SDK:
import { WebhookSchema, WebhookActionSchema } from 'marzban-sdk'
// Validate a full payload — returns a typed WebhookType on success
const result = WebhookSchema.safeParse(rawJson)
if (result.success) {
handleEvent(result.data)
} else {
console.error('Invalid webhook:', result.error.issues)
}
// Validate just an action name (e.g. from a query param or filter)
WebhookActionSchema.parse('user_created') // ok
WebhookActionSchema.parse('nope') // throws ZodErrorFor batches, is the inferred type of an array of events — exactly what parseWebhook returns.
WebSocket Logs
Stream live logs from the Marzban Xray core and individual nodes over WebSocket, with automatic token refresh and reconnection via `sdk.logs`.
Signature Verification
Verify Marzban webhook signatures with HMAC-SHA256 using the Web Crypto API — server-side only, with the x-signature header and your shared secret.