Gateway › Webhook

Webhook

Flow

Commands start with / inside the text field; see Chat commands.

Two separate directions:

  • Inbound: your chatbot POSTs to /webhook on agent-hub (127.0.0.1:8788).
  • Outbound: agent-hub POSTs a callback — your callback URL (set on the integration).
PayloadRole
no group_idDM
group_idGroup
group_id + topic_idTopic
+ thread_idThread inside a topic
reply_toReply 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 stringtimestamp + ”.” + the exact bytes of the body.
300 sA request whose clock skew exceeds 300 s is rejected — anti-replay.
comparisonUses 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 }
membersThe first message from someone in a group auto-registers them; member_left drops them.
is_botFlags a bot’s own messages — best not to relay other bots.
topic_createdShows the scope picker once in that topic (needs an owner already set).
attachmentsThe agent machine GETs the url itself (signed over an empty body), up to 20 MB.
plain textNot a command and not a reply to an agent’s message → ignored.
StatusWhen
200 {"ok":true}Received and handed to the core.
400Bad JSON, unknown event, missing/wrong field.
401Bad signature or clock skew — checked before parsing.
404 · 405Wrong path · wrong method.
408 · 413Body took over 10 s · body over 1 MB.
500Core 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_idReply with your own id; later edit and ack reuse it.
idThe same across every retry: skip an id you’ve already handled.
retrymessage: connection error / 5xx retries with increasing backoff; 4xx never retries; a timeout after sending counts as uncertain, never retried.
best-effortedit, 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.