MarzbanSDK
Webhooks

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:

FieldTypeDescription
actionWhich event fired — one of the 12 names below
usernamestringUsername of the affected user
enqueued_atnumberUnix timestamp (float) when the event was queued
send_atnumberUnix timestamp (float) when the event was sent
triesnumberNumber 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:

ActionExtra fieldsFires when
user_createduser, byA new user was created
user_updateduser, byA user's profile was modified
user_deletedbyA user was deleted (no user — it's gone)
user_enableduser, by?A user was re-enabled (by is null for an automated re-enable)
user_disableduser, by, reason?A user was disabled
user_limiteduserA user hit their data limit
user_expireduserA user's subscription expired
data_usage_resetuser, byA user's data usage was reset manually
data_reset_by_nextuserData was reset by the "next plan" mechanism
subscription_revokeduser, byA subscription token was revoked and reissued
reached_usage_percentuser, used_percentA user crossed a configured usage-percent threshold
reached_days_leftuser, days_leftA 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 ZodError

For batches, is the inferred type of an array of events — exactly what parseWebhook returns.

On this page