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-Event is the event type, the same as type in the body.
  • X-AgentServe-Delivery is 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:

  1. Read the raw body before any JSON parsing. Re-serialising the parsed JSON changes the bytes.
  2. Split the header on , and take t and v1.
  3. Compute the HMAC over t + "." + body and compare it to v1 in constant time.
  4. 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 endpointoutcome
2xx or 3xxDelivered. Redirects are not followed.
4xxFailed, not retried.
5xx, timeout or network errorRetried 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.

Stuck? Your agent can read /skill.md, or connect it over MCP.