Overview
This page covers the Managed Agents Webhook mechanism: turning "something happened" (session finished, deployment run ended, resource changed) into one active HTTP push instead of endless polling.
Relation to the series: the production page's Deployment answers "run on schedule", the multi-agent page's SSE answers "watch it live" — this page answers "tell me when it's done"; the three combined are the complete unattended loop. Basic subscription usage is in Webhook Event Subscriptions; this page goes deep on receiver engineering.
Scenario: Shibei Commerce's reconciliation notification
"Shibei Commerce" is a fictional cross-border e-commerce company that reconciles every night: a reconciliation robot (a cron-triggered Deployment) checks the day's order flow. The ops team's ask is plain:
I don't want to babysit the task — when it finishes, or fails, notify my server.
Before Webhooks there was only pull: poll GET /deployments/{id}/runs and GET /sessions/{id} to repeatedly check whether the task is done. Webhooks invert it — you subscribe to events, and the platform POSTs your receiver when they happen:
pull (polling): you → platform "done yet?" ×N times
push (Webhook): platform → you "done" ×1 time
What a Webhook is
- A workspace-level standalone resource at the same level as agent, session, deployment; id prefix
wep_. Up to 20 per workspace.
- Subscribes to named events: 32 in total across 9 classes; no
* wildcard:
| Class | Events |
|---|
| Session control plane | session.created / updated / archived / deleted |
| Session runtime state | session.status_run_started / status_idled / status_terminated |
| Session Thread | session.thread_created / thread_run_started / thread_idled / thread_terminated |
| Agent | agent.created / updated / archived |
| Deployment | deployment.created / updated / archived / paused / unpaused |
| Deployment Run | deployment_run.started / failed / succeeded |
| Environment | environment.created / updated / archived / deleted |
| Vault | vault.created / archived / deleted |
| Vault Credential | vault_credential.created / archived / deleted |
- Events carry no resource content: the body has only resource ids and the event type; to know the reconciliation result, take
data.id and query the corresponding API.
Create a Webhook
curl -s -X POST "$AGENTSTUDIO_URL/webhook_endpoints" \
-H "Authorization: Bearer $DASHSCOPE_API_KEY" -H "Content-Type: application/json" \
-d '{
"description": "拾贝电商 对账任务通知",
"url": "http://118.178.120.71:8080/hook",
"events": ["session.status_run_started", "session.status_idled", "deployment_run.started", "deployment_run.succeeded", "deployment_run.failed"]
}'
For $AGENTSTUDIO_URL and $DASHSCOPE_API_KEY, see the production page. Response (key fields):
{
"id": "wep_01M1G63CXHX6C7C1801RD2J19W",
"url": "http://118.178.120.71:8080/hook",
"events": ["session.status_run_started", "session.status_idled", "..."],
"status": "ACTIVE",
"consecutive_fail": 0,
"last_success_at": null,
"signing_secret": "whsec_XgYOlI...",
"created_at": "..."
}
Three must-read details:
signing_secret appears exactly once — in the create (and reset_secret) response; no query endpoint returns it afterward. Save it at creation; lost means reset.
status is uppercase ACTIVE (deployment uses lowercase active); match status values case-sensitively in client code.
- Plain HTTP URLs are accepted too (delivery to a public-IP
http:// works); HTTPS is recommended for production.
URL validation
The platform applies blacklist-style validation on url:
| Address | Result |
|---|
webhook.site, *.beeceptor.com (temporary receivers) | Rejected — invalid webhook url (11800016); temporary debug sites are deliberately blocked |
http://127.0.0.1:8080/hook (loopback/private) | Rejected — the docs explicitly forbid private, loopback, link-local, and reserved addresses |
httpbin.org, example.com, *.vercel.app, *.ngrok-free.app | Pass |
http://<public IP>:8080/hook | Pass |
An independent validation: an unrecognized name in the events array (e.g. webhook.test) raises invalid webhook events — webhook.test is not a subscribable event; it is the synthetic one-shot event used by the test endpoint (below).
The receiver: 60 lines of Python
Run this receiver on the ops server; it logs every delivery's raw request (headers + body) as JSONL and offers read-back endpoints:
#!/usr/bin/env python3
# receiver.py —— Webhook receiver
import json, os, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = "/root/webhook_hits.jsonl"
class H(BaseHTTPRequestHandler):
def _send(self, code, body):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n)
if self.path != "/hook":
return self._send(404, b'{"error":"not found"}')
rec = {"ts": time.time(), "path": self.path,
"headers": dict(self.headers.items()),
"body": raw.decode("utf-8", "replace")}
with open(LOG, "a") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
self._send(200, b'{"ok":true}')
def do_GET(self):
if self.path == "/health":
return self._send(200, b'{"status":"up"}')
if self.path == "/dump":
data = open(LOG).read() if os.path.exists(LOG) else ""
return self._send(200, data.encode())
if self.path == "/clear":
open(LOG, "w").close()
return self._send(200, b'{"cleared":true}')
self._send(404, b'{"error":"not found"}')
def log_message(self, *a):
pass
ThreadingHTTPServer(("0.0.0.0", 8080), H).serve_forever()
A production receiver needs at least two more things: signature verification (next section) and fetching resource details by data.id (events carry no content).
Deployment pitfalls
nohup ... & dies with the SSH session. Run it under systemd (Restart=always):
# /etc/systemd/system/webhook-receiver.service
[Unit]
Description=Bailian webhook receiver
After=network.target
[Service]
ExecStart=/usr/bin/python3 /root/receiver.py
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
systemctl enable --now webhook-receiver
- One shell pitfall: remotely killing the receiver with
pkill -f receiver.py matches the SSH remote command line itself (the command line contains "receiver.py"), killing your own session (exit 255) — kill by port instead: fuser -k 8080/tcp.
The test endpoint: self-check before going live
curl -s -X POST "$AGENTSTUDIO_URL/webhook_endpoints/wep_xxx/test" -H "Authorization: Bearer $DASHSCOPE_API_KEY"
On success it synchronously returns an event envelope (and really delivers one webhook.test event to your receiver):
{"type": "event", "id": "whe_01M1G6C24QPR2V9J2QTKFXJCTH",
"created_at": "2026-09-02T04:34:54.743Z",
"data": {"id": "wep_01M1G63CXHX6C7C1801RD2J19W", "type": "webhook.test", "workspace_id": "ws_xxx"}}
On failure it returns HTTP 502 + error code 11800015, with the receiver's response echoed directly (typical samples: Webhook endpoint returned HTTP 302, Webhook endpoint returned HTTP 500, webhook network request failed) — that echo is first-hand evidence when debugging.
Three special semantics of test:
- One synchronous attempt only; no retry;
- Does not affect
consecutive_fail (after the receiver returns 500, test leaves the count at 0);
- It fires the synthetic
webhook.test event, not a subscribed real event.
What a real event looks like
Send any session a message (or trigger a deployment run) and deliveries appear in the receiver's log. A delivery sample (truncated):
POST /hook HTTP/1.1
User-Agent: Bailian-ManagedAgent-Webhook/1.0
webhook-id: whe_01M1G69TMRHC5ECD4PFE598GYF
webhook-timestamp: 1788323621
webhook-signature: v1,naL38RUIYmTbeQuXfXTJSigDA6J42dNA6Aa4R+1mKK4=
Content-Type: application/json; charset=utf-8
{"type":"event","id":"whe_01M1G69TMRHC5ECD4PFE598GYF","created_at":"2026-09-02T04:33:41.508Z",
"data":{"id":"sesn_01M1G5RPKRFHPTB72W2HG05KDM","type":"session.status_idled","workspace_id":"llm-czal8nvvwb8d47ks"}}
Field by field:
| Field | Meaning | Note |
|---|
webhook-id | The delivered event's id | Equals the body's outer id — use it for idempotent dedup |
webhook-timestamp | Delivery time (Unix seconds) | Regenerated per delivery (retries included) |
webhook-signature | v1, + Base64(HMAC-SHA256) | Verification below |
body id / created_at | Event identity / occurrence time | Unchanged on retries; sort by these |
body data.id | Resource id (session/agent/...) | Use it to query details |
body data.type | Event name | e.g. session.status_idled |
Thread-class events carry one extra field session_thread_id in data; Vault Credential events carry vault_id.
Signature verification: the receiver's first gate
The platform signs every delivery with the signing_secret from creation. The receiver must verify signatures — otherwise anyone holding the URL can forge notifications.
The algorithm
signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
key = Base64Decode( secret with the whsec_ prefix stripped ) # not the whole whsec_ string as the key
signature = Base64( HMAC-SHA256(key, UTF8(signed_content)) )
The three easiest mistakes:
- Key derivation:
whsec_ is a prefix marker; strip it and Base64-decode to get the real key bytes. Using the whsec_... string directly as the key always fails.
- Raw body: verify against the un-deserialized raw request bytes.
json.loads-then-re-serialize changes field order/whitespace and breaks the signature.
- Time window: check
webhook-timestamp is within 5 minutes of now (anti-replay) and compare with constant time (hmac.compare_digest, anti-timing side channel).
A complete verification script (over the log, line by line):
import json, base64, hmac, hashlib, sys
secret = sys.argv[1]
secret_bytes = base64.b64decode(secret[len("whsec_"):]) # strip prefix, then decode
for line in open("/root/webhook_hits.jsonl"):
rec = json.loads(line)
h = rec["headers"]
wid, ts, sig = h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"]
version, signature = sig.split(",", 1) # "v1,<base64>"
signed = f"{wid}.{ts}.".encode("utf-8") + rec["body"].encode("utf-8")
expected = base64.b64encode(
hmac.new(secret_bytes, signed, hashlib.sha256).digest()).decode()
print(h["webhook-id"], hmac.compare_digest(signature, expected))
Production advice: in your web framework, read request.body (raw bytes) plus the three headers, verify first, then parse JSON — the order matters.
Delivery semantics
The behaviors below are where production incidents concentrate; understand them fully before integrating.
Reordering is real
One session run produces run_started and idled events. After a brief receiver outage, the arrival order can invert: idled first; run_started only after the retry chain completes.
Never derive resource state from arrival order. Sort by the body's created_at; for final state, query the API with data.id.
At-least-once: duplicate deliveries exist
The same event can be delivered more than once — retries count as failures too, e.g. consecutive_fail jumping 0 → 2.
Idempotent dedup by the outer id (= webhook-id) is not "best practice" — it is the entry requirement. The receiver records processed event ids and drops repeats on arrival.
Retries and disabling (response-code semantics)
| Receiver response / error | Platform behavior |
|---|
| 200~299 | Delivered; no retry |
| 300~399 | Redirect not followed; no retry; immediately disables the Webhook |
| 408 / 425 / 429 | Current request failed; enters retry |
| 400~499 others | Current delivery permanently failed; no retry |
| 500~599 | Current request failed; enters retry |
| DNS / connect / read-write timeout | Current request failed; enters retry |
| HTTPS certificate / hostname validation failure | No retry; immediate disable |
| Resolves to private / reserved address | Connection forbidden; immediate disable |
- Retry cadence: at most 3 retries after the first failure, spaced 10 s, 30 s, 1 min (up to 4 real network requests total), then a final failure is recorded.
- 3xx disables immediately:
status: "DISABLED", disabled_reason: "REDIRECT_RESPONSE", and consecutive_fail increments (observed as 1 after a 302) — the disabled state and the failure counter are not mutually exclusive; check both when debugging.
- Auto-disable: 20 consecutive final failures of business events.
- The receiver should respond within 5 seconds (status code only, no body needed) — do no heavy work synchronously in the callback; store it and process async.
Current behavior: deployment_run.* events are not delivered. The create API accepts the deployment_run.started / succeeded / failed names, but after you trigger a deployment run these events themselves are not delivered; the session events they generate (session.status_run_started / session.status_idled) deliver normally.Practical advice for "notify me when done": use session.status_idled (data.id being the run's created session) as the completion signal, then query the runs API for status — the currently reliable path. Before any event type becomes a production dependency, verify it with your own receiver first.
Day-to-day operations
# The health triad: last_success_at / last_failure_at / consecutive_fail
curl -s "$AGENTSTUDIO_URL/webhook_endpoints" -H "Authorization: Bearer $DASHSCOPE_API_KEY" | python3 -m json.tool
{"id": "wep_...", "status": "ACTIVE", "disabled_reason": null,
"last_success_at": "2026-09-01T19:53:00.350Z", "last_failure_at": null,
"consecutive_fail": 0}
last_success_at / last_failure_at currently drift by about 8 hours (a timezone serialization issue): a real failure at 15:39 UTC was recorded in last_failure_at as 07:39 UTC, while updated_at in the same response is the correct 15:39 UTC. Compute health latency from updated_at, not these timestamps; until the field is fixed, apply the 8-hour correction before comparing.
consecutive_fail is the consecutive-failure count; one success resets it. Approaching 20 is the pre-disable signal — wire an alert.
- Key rotation:
POST /webhook_endpoints/{id}/reset_secret → the response returns the new secret (the old one fails immediately; the receiver must hot-swap, or rotate during a traffic valley).
- Delete:
DELETE /webhook_endpoints/{id} (200, a real delete; contrast agents/deployments, which are archive-only).
- Change url / events with
PUT /webhook_endpoints/{id} (body carries description / url / events; omitted fields unchanged; the secret is not rotated). There are also POST /{id}/enable / POST /{id}/disable and GET /{id}/events for the last 7 days of delivery history.
- Subscription changes replay no history — a new subscription applies only to events after it.
Summary
| Want | Use |
|---|
| Run tasks on a schedule or manually | Deployment (production page) |
| Watch execution live | SSE stream (multi-agent page) |
| Notify the server when done / failed | Webhooks (this page) |
| Business results in the notification | Webhook gives the signal (data.id); query the API for content |
Receiver engineering checklist (in priority order): verify signatures (strip prefix, decode key, raw bytes, time window, constant-time compare) → idempotent dedup (by event id) → fetch-on-demand (data.id) → respond within 5 s, heavy work async → systemd persistence (do not trust nohup) → monitor consecutive_fail.
Webhooks are the unified egress for resource state changes: session, agent, deployment, environment, and vault control-plane actions all have events. Add routing to this receiver skeleton and it grows into a lightweight resource-change bus.
Next steps