Gateway › Webhook
Webhook
Flow
Commands start with / inside the text field; see Chat commands.
Two separate directions:
- Inbound: your chatbot
POSTs to/webhookon agent-hub (127.0.0.1:8788). - Outbound: agent-hub
POSTs a callback — your callback URL (set on the integration).
| Payload | Role |
|---|---|
no group_id | DM |
group_id | Group |
group_id + topic_id | Topic |
+ thread_id | Thread inside a topic |
reply_to | Reply to a message |
Configuration
On the web app Integrations page, New integration: Id, Name, Mode (pull, callback or connector), Callback URL for callback mode, Owner user id (optional); then on the Gateways card click Enable on the webhook row. Credentials (secret, callback URL, owner) are stored by agent-hub, not in .env.
Any id in a payload is a non-empty string with no ”:” or whitespace, and group_id must not start with “dm.”.
Signing
X-Hub-Timestamp: <unix seconds>
X-Hub-Signature-256: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>
import hashlib, hmac
signature = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
| signed string | timestamp + ”.” + the exact bytes of the body. |
| 300 s | A request whose clock skew exceeds 300 s is rejected — anti-replay. |
| comparison | Uses compare_digest / timingSafeEqual. |
Inbound
SECRET="<integration secret>"
BODY='{"event":"message","message_id":"m1","user_id":"u42","user_name":"an","group_id":"g1","topic_id":"t1","text":"/agent dev status"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS -X POST http://127.0.0.1:8788/webhook \
-H "Content-Type: application/json" -H "X-Hub-Timestamp: $TS" \
-H "X-Hub-Signature-256: sha256=$SIG" -d "$BODY"
message: { event:"message", message_id, user_id, user_name, display_name?, is_bot?,
group_id?, topic_id?, thread_id?, text,
reply_to?: { text, user_id, user_name? },
attachments?: [{ id, name, mime, url }] }
button: { event:"button", callback_id, message_id, action, user_id, user_name,
group_id?, topic_id?, thread_id? }
topic_created: { event:"topic_created", group_id, topic_id, name }
member_left: { event:"member_left", group_id, user_id }
| members | The first message from someone in a group auto-registers them; member_left drops them. |
is_bot | Flags a bot’s own messages — best not to relay other bots. |
topic_created | Shows the scope picker once in that topic (needs an owner already set). |
attachments | The agent machine GETs the url itself (signed over an empty body), up to 20 MB. |
| plain text | Not a command and not a reply to an agent’s message → ignored. |
| Status | When |
|---|---|
200 {"ok":true} | Received and handed to the core. |
400 | Bad JSON, unknown event, missing/wrong field. |
401 | Bad signature or clock skew — checked before parsing. |
404 · 405 | Wrong path · wrong method. |
408 · 413 | Body took over 10 s · body over 1 MB. |
500 | Core error; may already be partially applied. |
Outbound
common: { id: uuid, type, ... }
chat: { user_id } | { group_id, topic_id?, thread_id? }
message: { type:"message", chat, text, buttons?: [{label, action}],
reply_markup: "markdown" | "plain" } → { message_id }
edit: { type:"edit", chat, message_id, text, buttons? }
ack: { type:"ack", chat, message_id, state }
typing: { type:"typing", chat }
callback_answer: { type:"callback_answer", callback_id, text?, alert }
message → message_id | Reply with your own id; later edit and ack reuse it. |
id | The same across every retry: skip an id you’ve already handled. |
| retry | message: connection error / 5xx retries with increasing backoff; 4xx never retries; a timeout after sending counts as uncertain, never retried. |
| best-effort | edit, ack, typing, callback_answer send once. |
import hashlib, hmac, http.server, json, os, time, uuid
SECRET = os.environ["HUB_SECRET"].encode()
class Receiver(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
ts = self.headers.get("X-Hub-Timestamp") or ""
sent = self.headers.get("X-Hub-Signature-256") or ""
good = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + body,
hashlib.sha256).hexdigest()
if (not ts.isdigit() or abs(time.time() - int(ts)) > 300
or not hmac.compare_digest(good, sent)):
self.send_response(401); self.end_headers(); return
event = json.loads(body)
reply = {"message_id": uuid.uuid4().hex} if event["type"] == "message" else {}
raw = json.dumps(reply).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers(); self.wfile.write(raw)
http.server.ThreadingHTTPServer(("127.0.0.1", 9000), Receiver).serve_forever()
The receiver must handle requests concurrently (ThreadingHTTPServer). POST /webhook only replies once the core has finished handling it, and the core may POST a callback back to the receiver while that is happening.
Exposing it
Keep the bind at 127.0.0.1. A chatbot on another machine uses a tunnel URL (/agent web): <tunnel_url>/webhook/<intg>, or a reverse proxy with TLS:
agent-hub.example.com {
reverse_proxy /webhook 127.0.0.1:8788
}
Sync the clocks of both machines with NTP — a skew over 300 s turns every request into a 401.