Webhooks
Receive run events over HTTP, verify the Standard Webhooks signature, and understand retries and delivery history.
Webhooks push run lifecycle events to your server. They follow the Standard Webhooks specification: JSON over HTTPS, signed with HMAC-SHA256, retried with backoff, and checked against SSRF. They are provided by the webhooks module, which is in the default MODULES set.
Register a webhook
A webhook belongs to a space (it fires for runs in that space) or to the organization (it fires for runs in every space). The body says which with level.
curl -X POST http://localhost:3000/api/webhooks \
-H "Authorization: Bearer $APPSTRATE_KEY" \
-H "Content-Type: application/json" \
-d '{
"level": "space",
"spaceId": "spc_...",
"url": "https://your-app.com/webhooks/appstrate",
"events": ["run.success", "run.failed", "run.timeout"],
"payloadMode": "full"
}'| Field | Description |
|---|---|
level | space or org. Required |
spaceId | Required when level is space. An API key can only create webhooks for its own space and cannot create org-level ones |
url | Your endpoint. https only. Private, loopback (localhost and every *.localhost name included), link-local, and reserved addresses are refused with blocked_url |
events | At least one event type, see below |
packageId | Optional. Only fire for runs of this agent |
payloadMode | full (default) or summary. Summary drops the run input and result |
enabled | Optional, defaults to true |
The response includes the signing secret (whsec_...) once. Store it. There is a limit of 20 webhooks per space and 20 per organization. To manage webhooks you need webhooks:* in the space, or org-webhooks:* (organization owners and admins) for org-level ones. The same endpoints list, update (PATCH), and delete webhooks.
Event types
| Event | Fired when |
|---|---|
run.started | A run began executing |
run.success | A run finished successfully |
run.failed | A run ended with an error |
run.timeout | A run exceeded its time limit |
run.cancelled | A run was cancelled |
run.connection_missing | A launch was refused because a required integration connection is missing or under-scoped. No run exists, so the payload object is a run_attempt carrying the agent, the actor, and the field errors |
Payload
Every delivery is a JSON event envelope:
{
"id": "evt_...",
"object": "event",
"type": "run.success",
"apiVersion": "2026-03-21",
"timestamp": "2026-10-05T10:31:12.345Z",
"data": {
"object": {
"object": "run",
"id": "run_...",
"packageId": "@acme/email-daily-digest",
"status": "success",
"duration": 18432,
"result": { "output": { "emailCount": 12 } }
}
}
}timestampis when the event occurred, RFC 3339. It is not the signing time: that is thewebhook-timestampheader.data.objectcarries the runid,packageId,status(the suffix of the event type, such assuccessorstarted), and on terminal eventsdurationin milliseconds. A failed run carrieserror. A successful run carriesresultinfullmode. A run launched inline (without a cataloged agent) carriespackage: { "ephemeral": true }.- The run object is capped at 256 KiB of serialized JSON. If it would exceed that,
resultis removed andresultTruncated: trueis set; if it still exceeds it,inputis removed too andinputTruncated: trueis set. Fetch the run through the API for the full data.
Verify the signature
Each request carries these headers:
| Header | Value |
|---|---|
webhook-id | The event id (evt_...). Stable across retries. Use it to deduplicate |
webhook-timestamp | Signing time, Unix seconds |
webhook-signature | One or more space-separated signatures, each v1,<base64> |
webhook-attempt | Delivery attempt number, starting at 1 |
content-type | application/json |
The signature is the base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{raw body}", with the secret as the key. The key is the part of the secret after whsec_, decoded from base64url.
import crypto from "node:crypto";
export function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const received = headers["webhook-signature"]; // "v1,xxx v1,yyy"
// Reject replays: more than 5 minutes of drift.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error("timestamp out of tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
const ok = received
.split(" ")
.map((s) => s.split(",")[1])
.some((sig) => {
const a = Buffer.from(sig ?? "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) throw new Error("signature mismatch");
}Verify against the raw request body, before any JSON parsing. The secret is base64url-encoded, not standard base64, so decode it as shown above.
Delivery and retries
- A delivery succeeds on any
2xx. Respond quickly and process the event asynchronously: each attempt times out after 15 seconds. - Up to 8 attempts in total. After a failed attempt the next one waits, in order: 30 seconds, 5 minutes, 30 minutes, 1 hour, 2 hours, 3 hours, 4 hours.
- A timeout, a network error, a
5xx, a408, a429, or a redirect is retried. Redirects are never followed: a signed payload is not re-sent to another address. - Any other
4xxis treated as permanent and not retried. If your endpoint is wrong, fix it and use the test ping. - A hostname that does not resolve is attempted at most three times: a miss on the third attempt is final.
- The target is resolved and checked again on every delivery. An address that now resolves to a private or reserved range is refused permanently.
- Events are delivered at least once and are not strictly ordered. Deduplicate on
webhook-id.
Delivery history
curl "http://localhost:3000/api/webhooks/wh_.../deliveries?limit=20" \
-H "Authorization: Bearer $APPSTRATE_KEY"Each delivery records eventId, eventType, status (success or failed), statusCode, latency, attempt, and error, newest first. Page with startingAfter, or follow the Link: <...>; rel="next" header. See API Reference.
Test ping
curl -X POST http://localhost:3000/api/webhooks/wh_.../test \
-H "Authorization: Bearer $APPSTRATE_KEY"Queues a real, signed test.ping delivery with a sample run object. It is attempted once and never retried, and the result shows up in the delivery history.
Rotate the secret
curl -X POST http://localhost:3000/api/webhooks/wh_.../rotate \
-H "Authorization: Bearer $APPSTRATE_KEY" \
-H "Content-Type: application/json" \
-d '{ "windowSeconds": 604800 }'The body is optional. The response returns the new secret, the secretPrevious, and rotationWindowEndsAt. During the window, which defaults to 7 days and is capped at 30 days, every delivery is signed with both secrets: webhook-signature carries two signatures and a verifier that knows either one accepts it. Deploy the new secret on your receiver at any point inside the window. After it closes, only the new secret is used.
Next
- Webhooks feature guide: managing subscriptions from the dashboard.
- Errors and Idempotency: creating webhooks safely.