Webhook Callback Contract
Describes the inbound HTTP request the platform sends to your callback URL when delivering events: request method and headers, the signature verification algorithm, event envelope fields, and idempotency, retry, and delivery guarantees. The event body carries only resource identifiers; query the latest state via data.id.
Overview
After you create a Webhook and subscribe to named events, the platform delivers a notification to the configured callback URL whenever an event occurs. This page describes the inbound request contract the platform sends: your receiver only needs to verify the signature per this contract, parse the event envelope, and query the latest resource state via data.id.
The event body does not carry the full resource content — only the event type, resource identifiers, and essential context. The receiver queries the latest resource by calling the corresponding GET endpoint with data.id.
Supported events
events supports the following 32 named events. Wildcard subscriptions such as * are not supported.
| Category | Events |
|---|---|
| Session management plane | session.created, session.updated, session.archived, session.deleted |
| Session run status | session.status_run_started, session.status_idled, session.status_terminated |
| Session Thread | session.thread_created, session.thread_run_started, session.thread_idled, session.thread_terminated |
| Agent | agent.created, agent.updated, agent.archived |
| Deployment | deployment.created, deployment.updated, deployment.archived, deployment.paused, deployment.unpaused |
| Deployment Run | deployment_run.started, deployment_run.failed, deployment_run.succeeded |
| Environment | environment.created, environment.updated, environment.archived, environment.deleted |
| Vault | vault.created, vault.archived, vault.deleted |
| Vault Credential | vault_credential.created, vault_credential.archived, vault_credential.deleted |
Callback request
The platform sends a POST request to an HTTP or HTTPS address that passes the security check, and does not follow redirects. A public IP entered directly is allowed for delivery; private, loopback, link-local, and reserved addresses are refused.
Each delivery regenerates webhook-timestamp and computes the signature using the current signing secret. On retries, the event body, outer id, and created_at remain unchanged.
Request headers example:
POST /managedagent/webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Bailian-ManagedAgent-Webhook/1.0
webhook-id: whe_01JXX8JY9BBM4BK4C2P7K3M3ZR
webhook-timestamp: 1785810621
webhook-signature: v1,BASE64_HMAC_SHA256
| Header | Description |
|---|---|
Content-Type | Always application/json |
User-Agent | Always Bailian-ManagedAgent-Webhook/1.0 |
webhook-id | The outer event.id, format whe_<ULID>; used for idempotent deduplication and as part of the signed content |
webhook-timestamp | Unix second-precision timestamp generated for this delivery; regenerated on each delivery |
webhook-signature | Signature, format v1, plus the Base64-encoded HMAC-SHA256 result |
Signature verification
Signed content and algorithm:
signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
secret_bytes = Base64Decode(RemovePrefix(signing_secret, "whsec_"))
signature = Base64(HMAC-SHA256(secret_bytes, UTF8(signed_content)))
- The value of
webhook-idis the outerevent.id, not thewebhook_idof the Webhook configuration. signing_secretiswhsec_plus standard Base64 text. When verifying, strip thewhsec_prefix and decode the remainder with standard Base64; do not use the fullwhsec_...string directly as the HMAC key.- Read the unmodified raw request body to verify the signature first, then perform JSON deserialization.
- Verify that the timestamp is within 5 minutes of the current time, and use a constant-time comparison to verify the signature.
WarningYou must verify the signature against the unparsed raw request body, then deserialize the JSON. Any re-encoding or parsing of the body before verification will cause the signature to mismatch.
NoteThe signing secret is returned only in the successful responses of Create and reset-secret; later queries do not return it. Store it securely.
Event envelope fields
A regular event body:
{
"type": "event",
"id": "whe_01JXX8JY9BBM4BK4C2P7K3M3ZR",
"created_at": "2026-08-06T10:30:21.123Z",
"data": {
"id": "sesn_xxx",
"type": "session.status_idled",
"workspace_id": "ws_xxx"
}
}
Thread events additionally carry session_thread_id: data.id is the session identifier, and data.session_thread_id is the specific thread identifier. session.thread_created, session.thread_run_started, session.thread_idled, and session.thread_terminated all use this structure:
{
"type": "event",
"id": "whe_01JXX8JY9BBM4BK4C2P7K3M3ZR",
"created_at": "2026-08-06T10:30:21.123Z",
"data": {
"id": "sesn_xxx",
"type": "session.thread_idled",
"workspace_id": "ws_xxx",
"session_thread_id": "sthread_xxx"
}
}
| Field | Type | Description |
|---|---|---|
type | string | Always event |
id | string | Event ID, format whe_<ULID>; all delivery attempts for the same event share the same outer id, matching the webhook-id header |
created_at | string | Event creation time, ISO 8601 |
data | object | Event payload |
data.id | string | Resource identifier; for Thread events, this is the Session identifier |
data.type | string | Event type |
data.workspace_id | string | Workspace ID |
data.session_thread_id | string | For Thread events, the specific Thread identifier |
data.vault_id | string | Carried in Vault Credential events |
Idempotency, retry, and delivery guarantees
The receiver does not need to return a response body and should return a status code within 5 seconds. The platform decides subsequent behavior based on the receiver's response or error:
| Receiver response or error | Platform behavior |
|---|---|
| 200–299 | Delivery succeeded; no retry |
| 300–399 | Redirects are not followed; no retry; the current Webhook is disabled immediately |
| 408, 425, 429 | Current request failed; enters retry |
| Other 400–499 statuses | Current delivery ultimately failed; no retry |
| 500–599 | Current request failed; enters retry |
| DNS resolution, connection, write, or read timeout | Current request failed; enters retry |
| HTTPS certificate or hostname verification failure | No retry; the current Webhook is disabled immediately |
| Domain resolves to a private or reserved address | Connection is refused; the current Webhook is disabled immediately |
After the first request for a business event fails, it is retried up to 3 more times, with wait intervals of 10s, 30s, and 1min — up to 4 actual network requests in total. If the 4th attempt still fails, a final failure is recorded. By default, a Webhook is auto-disabled after 20 consecutive business events ultimately fail. webhook.test sends a single synchronous request only, is not retried, and does not affect the consecutive-failure count.
Other delivery semantics:
- Possible duplicates: The same event may be delivered multiple times, always with the same
event.id; deduplicate idempotently by that identifier. - No ordering guarantee: Events may arrive out of order; sort by
created_atwhen ordering is required, and treat the resource query result as the source of truth for final state. - Query retention: Delivered events are retained for 7 days, after which they can no longer be queried.
Verification example
import base64
import hashlib
import hmac
import os
import time
secret = os.environ["AGENT_WEBHOOK_SECRET"]
secret_bytes = base64.b64decode(secret[len("whsec_"):])
def verify_webhook(raw_body: bytes, webhook_id: str, timestamp: str, signature_header: str) -> bool:
try:
if abs(time.time() - int(timestamp)) > 300:
return False
version, signature = signature_header.split(",", 1)
if version != "v1":
return False
signed_payload = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(
hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(signature, expected)
except (TypeError, ValueError):
return False
import crypto from "node:crypto"
import express from "express"
const secret = process.env.AGENT_WEBHOOK_SECRET // whsec_...
const secretBytes = Buffer.from(secret.slice("whsec_".length), "base64")
const app = express()
// You must use the raw request body; it cannot be parsed by express.json() first.
app.post("/agent/events", express.raw({ type: "*/*" }), (req, res) => {
const webhookId = req.get("webhook-id") ?? ""
const timestamp = req.get("webhook-timestamp") ?? ""
const [version, signature = ""] = (req.get("webhook-signature") ?? "").split(",", 2)
const timestampSeconds = Number(timestamp)
if (!timestampSeconds || version !== "v1" || Math.abs(Date.now() / 1000 - timestampSeconds) > 300) {
return res.status(401).send("invalid webhook signature")
}
const expected = crypto
.createHmac("sha256", secretBytes)
.update(`${webhookId}.${timestamp}.`)
.update(req.body)
.digest()
const actual = Buffer.from(signature, "base64")
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
return res.status(401).send("invalid webhook signature")
}
const event = JSON.parse(req.body.toString("utf8"))
res.status(200).end()
enqueue(event)
})