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"
  }'
FieldDescription
levelspace or org. Required
spaceIdRequired when level is space. An API key can only create webhooks for its own space and cannot create org-level ones
urlYour endpoint. https only. Private, loopback (localhost and every *.localhost name included), link-local, and reserved addresses are refused with blocked_url
eventsAt least one event type, see below
packageIdOptional. Only fire for runs of this agent
payloadModefull (default) or summary. Summary drops the run input and result
enabledOptional, 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

EventFired when
run.startedA run began executing
run.successA run finished successfully
run.failedA run ended with an error
run.timeoutA run exceeded its time limit
run.cancelledA run was cancelled
run.connection_missingA 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 } }
    }
  }
}
  • timestamp is when the event occurred, RFC 3339. It is not the signing time: that is the webhook-timestamp header.
  • data.object carries the run id, packageId, status (the suffix of the event type, such as success or started), and on terminal events duration in milliseconds. A failed run carries error. A successful run carries result in full mode. A run launched inline (without a cataloged agent) carries package: { "ephemeral": true }.
  • The run object is capped at 256 KiB of serialized JSON. If it would exceed that, result is removed and resultTruncated: true is set; if it still exceeds it, input is removed too and inputTruncated: true is set. Fetch the run through the API for the full data.

Verify the signature

Each request carries these headers:

HeaderValue
webhook-idThe event id (evt_...). Stable across retries. Use it to deduplicate
webhook-timestampSigning time, Unix seconds
webhook-signatureOne or more space-separated signatures, each v1,<base64>
webhook-attemptDelivery attempt number, starting at 1
content-typeapplication/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, a 408, a 429, or a redirect is retried. Redirects are never followed: a signed payload is not re-sent to another address.
  • Any other 4xx is 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

On this page