Skip to main content

Advanced: Webhook Event Notifications

Turn "something happened" (session finished, deployment run ended, resource changed) into an active HTTP push — receiver engineering, signature verification, and the delivery semantics behind retries and disabling.

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:
ClassEvents
Session control planesession.created / updated / archived / deleted
Session runtime statesession.status_run_started / status_idled / status_terminated
Session Threadsession.thread_created / thread_run_started / thread_idled / thread_terminated
Agentagent.created / updated / archived
Deploymentdeployment.created / updated / archived / paused / unpaused
Deployment Rundeployment_run.started / failed / succeeded
Environmentenvironment.created / updated / archived / deleted
Vaultvault.created / archived / deleted
Vault Credentialvault_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:
  1. 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.
  2. status is uppercase ACTIVE (deployment uses lowercase active); match status values case-sensitively in client code.
  3. 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:
AddressResult
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.appPass
http://<public IP>:8080/hookPass
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:
  1. One synchronous attempt only; no retry;
  2. Does not affect consecutive_fail (after the receiver returns 500, test leaves the count at 0);
  3. 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:
FieldMeaningNote
webhook-idThe delivered event's idEquals the body's outer id — use it for idempotent dedup
webhook-timestampDelivery time (Unix seconds)Regenerated per delivery (retries included)
webhook-signaturev1, + Base64(HMAC-SHA256)Verification below
body id / created_atEvent identity / occurrence timeUnchanged on retries; sort by these
body data.idResource id (session/agent/...)Use it to query details
body data.typeEvent namee.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:
  1. 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.
  2. Raw body: verify against the un-deserialized raw request bytes. json.loads-then-re-serialize changes field order/whitespace and breaks the signature.
  3. 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 / errorPlatform behavior
200~299Delivered; no retry
300~399Redirect not followed; no retry; immediately disables the Webhook
408 / 425 / 429Current request failed; enters retry
400~499 othersCurrent delivery permanently failed; no retry
500~599Current request failed; enters retry
DNS / connect / read-write timeoutCurrent request failed; enters retry
HTTPS certificate / hostname validation failureNo retry; immediate disable
Resolves to private / reserved addressConnection 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

WantUse
Run tasks on a schedule or manuallyDeployment (production page)
Watch execution liveSSE stream (multi-agent page)
Notify the server when done / failedWebhooks (this page)
Business results in the notificationWebhook 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

Agent Context
Agent Governance & Evolution
Store
Application Marketplace