Docs / Guides
Webhooks
Receive every lifecycle event of a stack as a signed JSON POST, and verify the signature.
A stack can push each of its lifecycle events to a URL you choose: when it goes live, when a redeploy fails, when a service crashes, when it's about to expire, when it's claimed. Deliveries are JSON POSTs, signed with a secret that only the deployer receives.
Set the webhook#
Add notify.webhook to the manifest:
name: todo-app
notify:
webhook: https://example.com/hooks/agentserve
services:
...
The webhook receives every event of the stack. There is no per-event filter; ignore the types you don't need. For the full list of event types and their fields, see Events.
The secret#
When the stack is created with a webhook, the response includes a webhook_secret
(whsec_…). It is returned once, so store it with the receiving end. The CLI's summary doesn't print it;
use agentserve deploy --json to see the full response. The MCP tool deploy_stack returns it
in its result.
If the stack was created without a webhook and a later redeploy adds one, that redeploy's response carries the
secret instead. Changing the URL in a later redeploy points deliveries at the new URL and keeps the same secret.
Removing notify from the manifest doesn't turn deliveries off: the last URL stays in place.
What a delivery looks like#
POST /hooks/agentserve HTTP/1.1
Content-Type: application/json
X-AgentServe-Event: stack.live
X-AgentServe-Delivery: 42
X-AgentServe-Signature: t=1760000000,v1=5f2b9c…
{"id": 42, "stack_id": "stk_3f9a1c2b7d4e", "type": "stack.live",
"data": {"generation": 2, "urls": {"api": "https://api-todo-app-8c1f.agentserve.sh", "web": "https://web-todo-app-8c1f.agentserve.sh"}, "expires_at": 1760003600.0},
"created_at": 1760000000.12}
X-AgentServe-Eventis the event type, the same astypein the body.X-AgentServe-Deliveryis the event id. It stays the same across retries, so use it to drop duplicates.- The body has the same shape as the items returned by
GET /v0/stacks/{id}/events.
Verify the signature#
X-AgentServe-Signature has the form t=<unix seconds>,v1=<hex>. The
v1 value is the HMAC-SHA256, keyed with the webhook secret, of the string
"<t>.<raw body>": the timestamp, a dot, then the request body exactly as received.
To verify a delivery:
- Read the raw body before any JSON parsing. Re-serialising the parsed JSON changes the bytes.
- Split the header on
,and taketandv1. - Compute the HMAC over
t + "." + bodyand compare it tov1in constant time. - Reject timestamps that are too far from your clock. Each attempt is signed afresh, so retries carry a current timestamp.
import hashlib
import hmac
import time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
const crypto = require("node:crypto");
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)])
);
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.`)
.update(rawBody) // a Buffer with the exact request bytes
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
With Express, get the raw bytes with express.raw({ type: "application/json" }) on the webhook route,
then JSON.parse the body after verifying it.
Responses and retries#
Each attempt has a 5-second timeout. What happens next depends on your response:
| your endpoint | outcome |
|---|---|
| 2xx or 3xx | Delivered. Redirects are not followed. |
| 4xx | Failed, not retried. |
| 5xx, timeout or network error | Retried after 1s, 5s and 25s, so 4 attempts in total. |
Respond quickly with a 2xx and do slow work afterwards. Deliveries are sent in the background, and events can
arrive out of order when an earlier one is being retried; order them by id or created_at if
it matters.
Retries are held in memory only. If the AgentServe server restarts while a delivery is waiting for a retry, that
delivery is dropped. Every event is also stored, so you can catch up with
GET /v0/stacks/{id}/events?after=<last id>, which returns up to 200 events per call.
Polling instead#
If you can't receive webhooks, for instance because your agent runs on a laptop, poll the same events with
agentserve events or the events endpoint above. See Events for the types and
their payloads.