Outbound Event Webhooks¶
The XiansAi Server can notify external systems whenever a significant event happens on the platform — for example when a user is added, a tenant is created, or an agent is deleted. You register listener URLs in the server configuration, and the server delivers a signed HTTP POST to each listener that is subscribed to the event.
Outbound vs inbound webhooks
This page covers outbound notifications the server sends to your systems. It is different from inbound webhooks (Slack, Teams, generic webhooks) that receive HTTP calls to drive agents — see Slack Integration and Teams Integration.
How it works¶
Events are delivered through a durable outbox so they survive restarts and work correctly even when several server instances run at once.
sequenceDiagram
participant Service as Business service
participant Outbox as webhook_deliveries (Mongo)
participant Dispatcher as Webhook Dispatcher
participant Listener as Your listener
Service->>Outbox: Persist one delivery per matching subscription
Dispatcher->>Outbox: Atomically claim a batch (lease)
Dispatcher->>Listener: HTTP POST (signed)
Listener-->>Dispatcher: 2xx = delivered
Note over Dispatcher,Outbox: Non-2xx / timeout → retry with backoff - When an event occurs, the server writes one durable row per matching subscription into the
webhook_deliveriescollection. - A background dispatcher (running on every instance) atomically claims due rows using a short lease, so exactly one instance delivers each row.
- On a
2xxresponse the delivery is marked delivered. On failure it is retried with exponential backoff up to the configured maximum, after which it is marked failed and kept for inspection. - If an instance crashes mid-delivery, the lease expires and another instance automatically reclaims the row.
Registering a webhook¶
Webhooks are configured under the Webhooks section of your server configuration (appsettings.json or environment variables). Nothing is queued or delivered until the feature is enabled.
Environment variable form
Every setting can be supplied as an environment variable by replacing : with __. For example Webhooks:Enabled becomes Webhooks__Enabled.
Global settings¶
| Setting | Required | Default | Description |
|---|---|---|---|
Webhooks:Enabled | Yes | false | Master switch. When false, nothing is queued and the dispatcher does not run. |
Webhooks:Subscriptions | Yes | [] | The list of listeners (see below). At least one is required for anything to be delivered. |
Webhooks:PollIntervalSeconds | No | 5 | How often the dispatcher checks the outbox for due deliveries. |
Webhooks:RequestTimeoutSeconds | No | 10 | Per-request HTTP timeout for each delivery attempt. |
Webhooks:MaxAttempts | No | 5 | Number of attempts before a delivery is marked Failed. |
Webhooks:LeaseSeconds | No | 60 | Claim lease duration; also the crash-recovery window before a stuck delivery is retried by another instance. |
Webhooks:BatchSize | No | 20 | Maximum number of deliveries processed per poll. |
Webhooks:AllowInsecureHttp | No | false | Allow http:// listener URLs. When false, listener URLs must use HTTPS. |
Subscription settings¶
Each entry in Webhooks:Subscriptions describes one listener.
| Field | Required | Default | Description |
|---|---|---|---|
Url | Yes | — | Absolute destination URL that receives the POST. Must be HTTPS unless AllowInsecureHttp is true. |
Name | Yes | — | Stable identifier for the listener. It is persisted on every delivery row and useful for auditing and debugging. |
EventTypes | No | ["*"] | The event types this listener receives. Empty or ["*"] means all events. Otherwise, list specific event types (see Supported events). |
Secret | No (recommended) | — | Shared secret. When set, each request is signed with an HMAC-SHA256 signature so you can verify authenticity. Strongly recommended. |
Enabled | No | true | Whether this subscription is active. Set to false to temporarily disable a listener without removing it. |
Example (appsettings.json)¶
{
"Webhooks": {
"Enabled": true,
"PollIntervalSeconds": 5,
"RequestTimeoutSeconds": 10,
"MaxAttempts": 5,
"LeaseSeconds": 60,
"BatchSize": 20,
"AllowInsecureHttp": false,
"Subscriptions": [
{
"Name": "crm-listener",
"Url": "https://example.com/hooks/xians",
"Secret": "a-long-random-shared-secret",
"EventTypes": ["user.created", "user.tenant.added", "tenant.created"],
"Enabled": true
},
{
"Name": "audit-sink",
"Url": "https://audit.internal.example.com/events",
"Secret": "another-long-random-secret",
"EventTypes": ["*"],
"Enabled": true
}
]
}
}
Request format¶
The dispatcher sends POST <Url> with Content-Type: application/json and the following headers:
| Header | Description |
|---|---|
X-Xians-Event | Event type (for example user.created). |
X-Xians-Event-Id | Logical event id. Identical across every subscription that receives the same event. |
X-Xians-Delivery | Unique id of this specific delivery attempt row. |
X-Xians-Attempt | Attempt number, starting at 1. |
X-Xians-Timestamp | Unix time (seconds) when the request was signed. Bound into the signature to defend against replay. |
X-Xians-Signature | Present only when a Secret is configured: sha256=<hex HMAC of "{timestamp}.{body}">. |
Body envelope¶
Every request body has the same envelope. The data object varies by event type.
{
"eventType": "user.created",
"eventId": "0f4c9b1e8a...",
"tenantId": "acme",
"occurredAt": "2026-07-01T05:00:00Z",
"actor": {
"userId": "admin@acme.com",
"userType": "UserApiKey",
"tenantId": "acme",
"roles": ["TenantAdmin"]
},
"data": {
"userId": "user-123",
"email": "jane@acme.com",
"name": "Jane Doe",
"tenantId": "acme",
"role": "TenantUser"
}
}
| Field | Description |
|---|---|
eventType | The event that occurred. |
eventId | Logical id shared across all deliveries of this event. Use it to deduplicate. |
tenantId | The tenant the event targets. May be null for global events (for example some user or template events). |
occurredAt | UTC timestamp when the event was published. |
actor | Who triggered the event, taken from the authenticated request context. null for system-originated events. |
data | Event-specific payload. |
Secrets are never sent
Event payloads describe what changed (ids, names, scopes) but never contain secret values, API key material, certificate private keys, or message content.
Securing the calls¶
Verify the signature¶
When a Secret is configured, verify every request before trusting it:
- Read the
X-Xians-Timestampheader and reject the request if it is too old (for example, more than a few minutes) to defend against replay attacks. - Compute
HMAC-SHA256(secret, "{timestamp}.{rawRequestBody}"), hex-encode it, and compare it in constant time with the value aftersha256=in theX-Xians-Signatureheader. - Reject the request if the signatures do not match.
import hashlib, hmac, time
def verify(secret: str, timestamp: str, body: bytes, signature_header: str) -> bool:
# 1. Reject stale requests (5 minute window)
if abs(time.time() - int(timestamp)) > 300:
return False
# 2. Recompute the signature over "{timestamp}.{body}"
signed = f"{timestamp}.".encode() + body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# 3. Constant-time compare
return hmac.compare_digest(expected, signature_header)
Transport hardening¶
- HTTPS is required by default. Set
Webhooks:AllowInsecureHttp=trueonly for trusted internal listeners on plain HTTP, since payloads may contain personal data. - Redirects are not followed. A
3xxresponse is treated as a delivery failure. This prevents a compromised listener from redirecting requests toward internal endpoints. - Minimal client surface. The dispatcher does not read the response body, sends no cookies, and does not decompress responses, limiting the impact of a hostile or misbehaving listener.
- TLS is validated normally. There is no option to bypass certificate validation.
Delivery semantics¶
Delivery is at-least-once. A listener may occasionally receive the same event more than once (for example, if it returns 2xx after a network timeout). Always deduplicate using eventId.
- Respond with any
2xxstatus to acknowledge receipt. - Respond quickly. If your processing is slow, accept the request and process it asynchronously; otherwise you risk the
RequestTimeoutSecondstimeout and unnecessary retries. - Failed deliveries are retried with exponential backoff up to
MaxAttempts, then markedFailedand retained for inspection.
Supported events¶
Events are published from the server's shared services, so they fire regardless of whether the change came via the Admin API, Agent API, or Web API.
High-volume telemetry is not delivered
Per-message conversation writes, agent logs, activity history, usage metrics, ephemeral cache writes, and heartbeats are intentionally not emitted as webhooks, to avoid overwhelming listeners.
Tenants¶
| Event type | Description |
|---|---|
tenant.created | A new tenant was created. |
tenant.updated | A tenant's profile, theme, or logo was updated. |
tenant.enabled | A tenant was enabled. |
tenant.disabled | A tenant was disabled. |
tenant.deleted | A tenant was deleted. |
tenant.oidc.updated | A tenant's OIDC configuration was created or updated. |
tenant.oidc.deleted | A tenant's OIDC configuration was deleted. |
Users¶
| Event type | Description |
|---|---|
user.created | A new user account was created. |
user.tenant.added | An existing user was granted membership in a tenant. |
user.tenant.removed | A user's membership in a tenant was removed (also fires when their last role is removed). |
user.updated | A user's name or email was updated. |
user.approved | A user's tenant membership was approved. |
user.unapproved | A user's tenant membership approval was revoked. |
user.role.changed | A role was added to a user within a tenant. |
user.role.removed | A role was removed from a user within a tenant. |
user.sysadmin.granted | A user was granted the system administrator flag. |
user.sysadmin.revoked | A user's system administrator flag was revoked. |
user.enabled | A user account was enabled (unlocked). |
user.disabled | A user account was disabled (locked out). |
Agents, deployments, and templates¶
| Event type | Description |
|---|---|
agent.registered | An agent was registered for the first time (via the Agent API). |
agent.deleted | An agent and its dependent resources were deleted. |
agent.deployment.updated | An agent deployment's configuration was updated (via the Admin API). |
agent.ownership.transferred | Ownership of an agent was transferred to another user. |
agent.template.deployed | A system template agent was deployed into a tenant. |
template.updated | A system-scoped template agent's metadata was updated. |
template.deleted | A system-scoped template agent was deleted. |
Flow definitions¶
| Event type | Description |
|---|---|
flow.definition.created | A new workflow (flow) definition was registered. |
flow.definition.updated | An existing workflow definition changed (its hash changed). |
Activations¶
| Event type | Description |
|---|---|
activation.created | An agent activation was created. |
activation.updated | An agent activation was updated. |
activation.activated | An agent activation was activated and its workflows started. |
activation.deactivated | An agent activation was deactivated. |
activation.deleted | An agent activation was deleted. |
Knowledge¶
| Event type | Description |
|---|---|
knowledge.created | A knowledge item (or override/version) was created. |
knowledge.updated | A knowledge item was updated (a new version was stored). |
knowledge.deleted | A knowledge item was deleted. |
Secrets, keys, and certificates¶
| Event type | Description |
|---|---|
secret.created | A vault secret was created. |
secret.updated | A vault secret was updated. |
secret.deleted | A vault secret was deleted. |
apikey.created | An API key was created. |
apikey.revoked | An API key was revoked. |
apikey.rotated | An API key was rotated. |
certificate.created | A client certificate was issued. |
certificate.revoked | A client certificate was revoked. |
App integrations¶
| Event type | Description |
|---|---|
integration.created | An app integration was created. |
integration.updated | An app integration was updated. |
integration.deleted | An app integration was deleted. |
integration.enabled | An app integration was enabled. |
integration.disabled | An app integration was disabled. |
integration.webhook.created | A builtin webhook integration was created. (Deletion emits integration.deleted.) |
Auditing¶
Beyond delivering to listeners, every row in the webhook_deliveries collection records audit fields you can query directly in the database:
| Field | Description |
|---|---|
actor_user_id | The user who triggered the event (indexed with created_at for per-user audit queries). |
actor_user_type | How that user authenticated (for example UserApiKey). |
tenant_id | The event's target tenant. |
event_type, event_id | The event and its logical id. |
created_at | When the event was queued. |
status, attempt_count, delivered_at, last_error | The delivery outcome and history. |
Troubleshooting¶
Nothing is delivered: Confirm Webhooks:Enabled is true and that at least one subscription is defined and Enabled.
A listener receives no events but others do: Check the subscription's EventTypes — it may not include the event types you expect. Use ["*"] to receive everything.
Deliveries fail with a redirect: The dispatcher does not follow redirects. Configure the listener at its final URL.
Signature verification fails: Ensure you sign "{timestamp}.{rawBody}" (raw bytes, before any parsing) with the exact Secret, and compare against the value after sha256=.
Server refuses an http:// URL: HTTPS is required unless Webhooks:AllowInsecureHttp is true.