Docs / Reference
Events
Every lifecycle event a stack emits, when it fires, its payload, and how webhooks deliver and sign it.
Everything that happens to a stack is recorded as an event. Events can be polled with
GET /v0/stacks/{id}/events or agentserve events, and are pushed to the stack's webhook
if the manifest sets notify.webhook. Claimed projects also show them on the dashboard's activity
page.
Envelope#
Every event has the same shape, both from the API and in a webhook body:
{
"id": 57,
"stack_id": "stk_3f9a1c0b2d4e",
"type": "service.crashed",
"data": {"service": "api", "exit_code": 1, "crashes": 2},
"created_at": 1791647012.4
}
| Field | Type | Meaning |
|---|---|---|
id | integer | Increasing across the whole server, not per stack. Use it as the
after cursor when polling. |
stack_id | string | The stack. |
type | string | One of the types below. |
data | object | Type-specific payload. {} when there is none. |
created_at | number | Unix time in seconds. |
Event types#
Deploys#
| Type | Fires when | data |
|---|---|---|
stack.created | A stack is created, before its first build starts. | anonymous
(boolean): false when created with an API key. |
stack.redeploying | A redeploy was accepted and its build is starting. | generation:
the generation being built. |
service.building | Each service's install and build starts, in dependency order. | service,
generation |
service.ready | A service is serving: its process passed the health check, or the static site is in place. Fires on deploys, rollbacks and restores. | service, url |
stack.live | A deploy or redeploy finished and every service is up. | generation,
urls (service name to URL), expires_at (null if claimed) |
stack.deploy_failed | A redeploy failed to build or become healthy and the previous
generation is serving again. The stack stays live with deploy_error set. | generation
(the failed one), error (last 1,000 characters), serving_generation |
stack.failed | The stack stopped: the first deploy failed, a redeploy of a failed stack failed, a rollback couldn't bring the previous generation back, a service crashed too often, or a restore failed. | error (last 1,000 characters) |
Runtime#
| Type | Fires when | data |
|---|---|---|
service.crashed | The supervisor found a service's process exited while the stack is live.
Also fires when an automatic restart attempt itself fails, with exit_code: null. | service,
exit_code (integer or null), crashes (crashes in the last 10 minutes, including this one) |
service.restarted | A crashed service was restarted and is healthy, or a restart requested from the dashboard succeeded. | service; requested: true for a restart from the
dashboard |
After more than 5 crashes of one service within 10 minutes, the stack becomes failed and
stack.failed fires instead of a restart. See Limits.
Lifetime#
| Type | Fires when | data |
|---|---|---|
stack.extended | The TTL was extended. | expires_at |
stack.expiring | An unclaimed stack is 5 minutes from expiry (by default). Fires once; extending the TTL re-arms it. | expires_at, message: "Ask your human to claim this
stack, or extend the TTL." |
stack.expired | An unclaimed stack's TTL ran out and its processes were stopped. | retained_until
(when the snapshot is deleted), claim_url: the server's claim entry page (<base>/claim),
where a human types the code |
stack.claimed | The stack moved into an account, from the browser or
POST /v0/claim. | by: the account's email |
stack.restored | A stack came back without a rebuild: after a claim of an expired stack, and for every live stack when the server restarts. | urls |
stack.agent_revoked | The owner revoked the manage token in the dashboard. | none |
stack.deleted | The stack was deleted: through the API, the CLI, the dashboard, or because an unclaimed snapshot reached the end of its retention. | none |
A typical sequence#
stack.created {"anonymous": true}
service.building {"service": "api", "generation": 1}
service.building {"service": "web", "generation": 1}
service.ready {"service": "api", "url": "https://api-todo-app-3f9a.agentserve.sh"}
service.ready {"service": "web", "url": "https://web-todo-app-3f9a.agentserve.sh"}
stack.live {"generation": 1, "urls": {…}, "expires_at": 1791649931.2}
stack.expiring {"expires_at": 1791649931.2, "message": "Ask your human to claim this stack, or extend the TTL."}
stack.expired {"retained_until": 1792254731.2, "claim_url": "https://agentserve.sh/claim"}
stack.claimed {"by": "[email protected]"}
service.ready {"service": "api", …}
service.ready {"service": "web", …}
stack.restored {"urls": {…}}
All services are built first, then all are started: every service.building comes before the first
service.ready.
Polling#
GET /v0/stacks/{id}/events?after=<id> returns up to 200 events with an id greater than
after, oldest first. Start at after=0 and pass the last id you received. See
HTTP API.
Webhook delivery#
When the stack has a webhook URL, each event is POSTed to it as soon as it is recorded, on a background thread per event.
| Header | Value |
|---|---|
Content-Type | application/json |
X-AgentServe-Event | The event type, e.g. stack.live. |
X-AgentServe-Delivery | The event id. The same on every retry, so use it to deduplicate. |
X-AgentServe-Signature | t=<unix>,v1=<hex>. See below. |
- The body is the event envelope.
- Each attempt has a 5-second timeout.
- A 2xx or 3xx response is a success. A 4xx response is a failure and is not retried.
- Network errors and 5xx responses are retried after 1, 5 and 25 seconds: four attempts in total. Then the event is dropped from delivery; it can still be polled.
- Deliveries run in parallel, so they can arrive out of order. Order by
id. - Redirects are not followed.
Signature#
v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with the stack's
webhook_secret (whsec_…). t is the Unix time of the attempt, so it changes
between retries. The secret is returned once: in the create response when the manifest has
notify.webhook, or in the redeploy response that first adds one.
Verify against the raw bytes of the body, before parsing it, and reject old timestamps:
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
if 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, sig)
More on receiving webhooks in Webhooks.