Subscribe to run and approval events, verify Hive-Signature, handle retries idempotently, read the delivery ledger, test locally.
Webhooks are how Agentic Workforce ME tells your backend what happened without you polling: a run finished (with its output and cost), a run failed, a run is waiting for a person. Each delivery is a signed POST to a URL you register; you verify the signature over the raw body, de-duplicate on the event id, acknowledge quickly and do the real work asynchronously. This closes the loop the trigger opened.
POST /v1/webhooks with the URL and the event types you want. The response contains the signing secret once; store it as your HIVE_WEBHOOK_SECRET. An endpoint subscribes to up to 16 entries from the catalog below, or to "*" for everything. POST /v1/webhooks/:id/test sends a signed webhook.test so you can prove the receiver before wiring anything else.
TypeScript
const endpoint =await hive.webhooks.create({url:'https://api.acme.example/hive/webhook',events:['run.completed','run.failed','approval.requested'],description:'orders service',});// endpoint.secret is shown ONCE → store it as HIVE_WEBHOOK_SECRET
Shell
curl-X POST https://console.agenticworkforce.me/v1/webhooks \-H "Authorization: Bearer $HIVE_API_KEY"-H "X-Tenant-Id: $HIVE_TENANT_ID"\-H "Content-Type: application/json"\-d '{"url":"https://api.acme.example/hive/webhook","events":["run.completed","run.failed","approval.requested"]}'# → 201 { "id": "…", "url": "…", "events": […], "enabled": true, "secret": "…shown once…" }# Send a signed webhook.test right awaycurl-X POST https://console.agenticworkforce.me/v1/webhooks/$ENDPOINT_ID/test \-H "Authorization: Bearer $HIVE_API_KEY"-H "X-Tenant-Id: $HIVE_TENANT_ID"
The types below are the ones the runtime emits today; the API rejects a subscription to anything else. run.completed and run.failed fire for the top-level run only — the agent run you started, or the workflow run a trigger started. The child runs a workflow spawns for its agent nodes execute inline and emit no lifecycle webhooks of their own; read their outcome from the parent’s output (GET /v1/runs/:id → outputs.<node>.child_run_id). Filter on workflow_id / agent_id or on the run_id you stored when you started the work.
HMAC-SHA256 keyed with the endpoint secret (UTF-8), lowercase hex
Signed material
"<t>.<raw body>"
Replay window
300 s either side of your clock (default in the SDK helpers)
Compare
Constant time, after the timestamp check.
webhook.tsTypeScript
import express from'express';import{ verifyWebhookSignature }from'@hive/sdk';// or copy hive-signing.mjs from the exampleconst app =express();// Raw body FIRST — the signature covers the exact bytes the platform sent.
app.post('/hive/webhook', express.raw({type:'*/*'}),async(req, res)=>{const raw = req.body.toString('utf8');const check =verifyWebhookSignature(process.env.HIVE_WEBHOOK_SECRET!, req.get('hive-signature')??'', raw);if(!check.ok)return res.status(401).json({error: check.reason });// MALFORMED_HEADER | STALE_TIMESTAMP | SIGNATURE_MISMATCHconst event =JSON.parse(raw)as{id:string;type:string;data:Record<string, unknown>};if(awaitalreadyProcessed(event.id))return res.status(200).json({duplicate:true});// retries resend the same idawaitenqueue(event);// ACK fast, apply asynchronously
res.status(200).json({received:true});});
webhook.pyPython
from __future__ import annotations
import hashlib
import hmac
import re
import time
_HEADER = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")defverify_hive_webhook(secret: str,signature_header: str,raw_body: bytes,tolerance_sec: int =300,now: int |None=None)->bool:"""Hive-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>."""
match = _HEADER.match((signature_header or"").strip())if match isNone:returnFalse
ts, provided =int(match.group(1)), match.group(2)ifabs((now if now isnotNoneelseint(time.time()))- ts)>tolerance_sec:returnFalse# replay window
expected = hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8")+ raw_body,
hashlib.sha256).hexdigest()return hmac.compare_digest(expected.encode("ascii"), provided.encode("ascii"))if __name__ =="__main__":# Flask: request.get_data() is the RAW body — never re-serialize request.json.import json
import os
from flask importFlask, request
app =Flask(__name__)seen: set[str]=set()@app.post("/hive/webhook")defhive_webhook():
raw = request.get_data()ifnotverify_hive_webhook(os.environ["HIVE_WEBHOOK_SECRET"],
request.headers.get("Hive-Signature",""), raw):return{"error":"INVALID_SIGNATURE"},401
event = json.loads(raw)if event["id"]inseen:# retries resend the same idreturn{"duplicate":True},200
seen.add(event["id"])handle(event)# enqueue in production; ACK fastreturn{"received":True},200
C#
usingSystem.Linq;usingSystem.Security.Cryptography;usingSystem.Text;usingSystem.Text.Json;usingSystem.Text.RegularExpressions;staticboolVerifyHiveWebhook(string secret,string signatureHeader,byte[] rawBody,int toleranceSec =300){var m =Regex.Match(signatureHeader?.Trim()??"",@"^t=(\d+),v1=([0-9a-f]{64})$");if(!m.Success)returnfalse;long ts =long.Parse(m.Groups[1].Value);if(Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds()- ts)> toleranceSec)returnfalse;// replay windowusingvar hmac =newHMACSHA256(Encoding.UTF8.GetBytes(secret));byte[] signed =Encoding.UTF8.GetBytes(ts +".").Concat(rawBody).ToArray();// "<t>.<raw body>"string expected =Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant();// v1 is lowercase hex; ToHexString is uppercasereturnCryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected),Encoding.UTF8.GetBytes(m.Groups[2].Value));}// ASP.NET Core minimal API — read the raw body, verify, de-duplicate on the envelope id
app.MapPost("/hive/webhook",async(HttpRequest req)=>{usingvar ms =newMemoryStream();await req.Body.CopyToAsync(ms);byte[] raw = ms.ToArray();if(!VerifyHiveWebhook(Environment.GetEnvironmentVariable("HIVE_WEBHOOK_SECRET")!,
req.Headers["Hive-Signature"].ToString(), raw))returnResults.Unauthorized();var evt =JsonDocument.Parse(raw).RootElement;// evt["id"], evt["type"], evt["data"]returnResults.Ok(new{ received =true});});
Java
// Java 17+ (HexFormat)import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.util.HexFormat;import java.util.regex.*;staticbooleanverifyHiveWebhook(String secret,String signatureHeader,byte[] rawBody)throwsException{long toleranceSec =300;Matcher m =Pattern.compile("^t=(\\d+),v1=([0-9a-f]{64})$").matcher(signatureHeader ==null?"": signatureHeader.trim());if(!m.matches())returnfalse;long ts =Long.parseLong(m.group(1));if(Math.abs(System.currentTimeMillis()/1000- ts)> toleranceSec)returnfalse;// replay window (300 s)Mac mac =Mac.getInstance("HmacSHA256");
mac.init(newSecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),"HmacSHA256"));
mac.update((ts +".").getBytes(StandardCharsets.UTF_8));// "<t>.<raw body>"
mac.update(rawBody);String expected =HexFormat.of().formatHex(mac.doFinal());returnMessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), m.group(2).getBytes(StandardCharsets.UTF_8));}// Spring: @PostMapping("/hive/webhook") with @RequestBody byte[] rawBody and// @RequestHeader("Hive-Signature") String sig → verify, then de-duplicate on the JSON "id".
Raw body. Register the raw-body parser before any JSON parser on this route (Express express.raw, Flask request.get_data(), ASP.NET the request stream). Re-serialising the parsed JSON changes bytes and fails the check.
Helpers.verifyWebhookSignature(secret, header, rawBody) in @hive/sdk and verify_webhook_signature in the Python SDK return { ok, reason } with MALFORMED_HEADER, STALE_TIMESTAMP or SIGNATURE_MISMATCH. The Python sample on this page and the example’s hive-signing.mjs are executed by the portal’s tests against the platform’s own signer; the .NET and Java samples are checked for the same header, canonical string and algorithm.
Clock. The timestamp is the platform’s clock in Unix seconds; keep your receiver’s clock NTP-synced or widen the tolerance deliberately, never by disabling the check.
A delivery is successful on any 2xx. Anything else — a 4xx, a 5xx, a timeout, a TLS failure — is retried: 5 attempts with exponential backoff starting at 5 s (≈ 5 s, 10 s, 20 s, 40 s between attempts). After the last failure the delivery is marked failed; the ledger below shows the status code or transport error you returned.
Exactly the same bytes are sent on every retry (the envelope is stored once), with the same id, the same Hive-Delivery-Id and a fresh signature — computed at delivery time with the endpoint’s current secret, so after a rotation retries of earlier events arrive signed with the new one. De-duplicate on the envelope id (or the delivery id) in a store that survives restarts; answer 200 for a duplicate. Check the store before applying and mark the id after the apply succeeded (or after persisting the raw event): marking first turns a crash mid-apply into a silently lost event.
Acknowledge fast. Verify, record, respond, then process on a queue. A slow handler risks the delivery timeout and a redundant retry.
Ordering is not guaranteed. Retries and parallel workers can deliver approval.requested after run.completed for the same run. Treat each event as a fact with a created_at, and read GET /v1/runs/:id when you need the current state rather than inferring it from arrival order.
Disabled endpoints (PATCHenabled: false) skip delivery entirely; nothing is queued for later.
# Expose your local receiver with a public https URL
cloudflared tunnel --url http://localhost:4100# or: ngrok http 4100# Register https://<random>.trycloudflare.com/hive/webhook as the endpoint URL.# Self-hosted stack on your machine instead? Allow plain-http/private hosts for the worker:
A2A_ALLOW_INSECURE_HOSTS=localhost,host.docker.internal
Tunnels give you a public HTTPS URL for a laptop; register it and re-run your setup script when it changes. POST /v1/webhooks/:id/test is the fastest round trip.
If you run the stack yourself, the worker’s SSRF policy can allow-list private hosts through the deployment’s environment (A2A_ALLOW_INSECURE_HOSTS). Never do this in production.
Generate signed test payloads offline: buildSignatureHeader(secret, body) from @hive/core / @hive/sdk produces a valid header for any body, which is how the portal tests drive the example receiver.
POST/v1/webhooks/:id/rotate-secretMint a new signing secret (shown once). Every delivery after rotation — including retries of earlier events — is signed with the new secret: the worker reads the endpoint’s current secret at delivery time.
No overlap window. Deploy the new secret before the next retry of any failing delivery (attempts are 5 s, 10 s, 20 s and 40 s apart, about 75 s in total) or accept both secrets during the transition. For zero downtime, register a second endpoint, cut over, then delete the old one.
GET/v1/webhooks/:id/deliveriesThe delivery ledger — status, attempts, last HTTP status and error per event.