Agents APIAutomation

Webhooks

Be told when something happens in a session, without holding the event stream open.

A webhook is an HTTPS address on your server. When a session changes state, for example a run finishes or the agent asks for approval, LatentStack sends a signed POST to it. You don't keep a connection open, and your server can be down for a while: deliveries are retried for about three days, and every event is kept in a log you can read back.

Webhooks carry changes of state. The agent's live output, its text and tool calls, stays on the event stream.

Two ways to set one up

Registered endpointOn one session
Made withwebhooks.create()The webhook field when you create a session
HearsEvery session you create in the organization, and your schedules' skipped runsThat session only
SecretMade for you, shown onceYours, or made for you and shown once
LastsUntil you delete it7 days after the session ends
How many10 per person in each organization, unless your administrator set another numberNot counted

A schedule can have a webhook of its own, which hears every session it starts. See Schedules.

Register an endpoint

Hear about finished runs and approvals for all your sessions
const endpoint = await client.agents.webhooks.create({
  url: "https://example.com/hooks/latentstack",
  events: ["run.completed", "run.failed", "approval.requested"],
  description: "Production",
})
console.log(endpoint.id, endpoint.secret) // the secret is shown only now: store it
urlstringrequired
HTTPS on port 443, to a public host. At most 2,048 characters. It is checked now, and a URL that can't be delivered to is refused with the reason. See Where deliveries can go.
eventslistrequired
What to send: exact types such as run.completed, a family such as run.* or session.*, or * for everything. Up to 50 entries.
descriptionstringoptional
A note for yourself, up to 500 characters.
enabledbooleanoptionaldefault true
Off means nothing is sent to it.
  • The answer is the endpoint with its secret (whsec_…). It is shown only in this answer. Keep it with your receiver's settings.
  • The endpoint hears the sessions you create in the organization you're working in. Other people in the organization have their own.
  • Past the limit, create fails with endpoint_limit_reached. Delete one first.
  • Change url, events, description or enabled later with webhooks.update(). The other fields keep their values.
  • Deleting an endpoint marks the deliveries still waiting for it as failed.

A webhook for one session

Pass webhook when you create a session to hear about that session alone. This suits a job runner that starts one session per task and wants a callback to the task's own URL.

const session = await sessions.create({
  agent: { model: "anthropic/claude-sonnet-5.5" },
  input: "Fix the failing test in packages/core.",
  webhook: { url: "https://example.com/hooks/tasks/42", events: ["run.*", "session.stopped"] },
})
console.log(session.webhook?.secret) // made for you, shown only now
  • events defaults to ["*"].
  • The create answer has a webhook field: the endpoint, with session_id set. It has a secret only when LatentStack made one, and only in this answer.
  • Or bring your own secret, so one receiver can verify every session with the same key. It is whsec_ followed by the base64 of 24 to 64 random bytes.
  • It isn't listed with your registered endpoints and doesn't count toward their limit. Read, update, test and rotate it by its id like any other endpoint.
  • Your registered endpoints hear the session too, for the types they subscribe to.
  • It is removed 7 days after the session stops or fails, which is longer than the retries of its last events.
$echo "whsec_$(openssl rand -base64 32)"
Note
With an idempotency key, bring your own secret
A create repeated with the same idempotency key returns the first session without its webhook, so a secret made for you would be lost if the first answer never arrived. Pass your own secret when you also pass an idempotency key.

Events

typeSent whendata
session.readyA sandbox is ready: when the session starts, and each time it resumes.instance_number, instance_context
session.pausedThe session paused: you paused it, it was idle, it reached a spend limit, its sandbox reached its time limit, or its sandbox was lost.reason: requested, idle_timeout, the spend limit (max_turns, max_tokens, max_cost_usd), hard_limit, or why the sandbox was lost. With error when the sandbox was lost or a resume couldn't restore it. For a session made with outputs: outputs, copied as it paused.
session.stoppedThe session ended for good.stop_reason
session.failedThe session couldn't start, or failed for good.error
run.startedThe agent picked up a message.seq
run.completedA run finished, or you cancelled it. Sent after its patch and outputs are saved.seq, last_message, patches, and outputs for a session with outputs
run.failedA run stopped on an error. The session is still usable.seq, error, patches, and outputs
approval.requestedThe agent is waiting for your permission. See Approvals.request_id, action, resources, metadata
question.requestedThe agent asked you a question.request_id, questions
limit.warning80% of a spend cap is used. Once per cap.limit, max, spent
limit.reachedA spend cap was reached. The agent wraps up, then the session pauses until the cap is raised.limit, max, spent
artifact.readyA patch with changes was saved at the end of a run or when the session stopped. Sent once per patch: the same changes are not reported twice. See Repositories and patches.The patch's record (repo, version, size, files, …), without its content
schedule.skippedA schedule's time came and no session was started. See Schedules.schedule_id, due_at, trigger, reason, message
webhook.testYou asked for a test. Sent whatever the endpoint subscribes to.endpoint_id, message

In events, a family is the part before the dot: session.*, run.*, approval.*, question.*, limit.*, artifact.* or schedule.*. New event types can appear over time. Answer 2xx to any you don't recognise.

run.completed

FieldMeaning
seqThe number of the run's last event on the session's event stream.
last_messageThe agent's last reply. Cut to 8 KB, with last_message_truncated: true. null when there is none.
patchesOne per repository: repo, version, size, files_changed, final. Download the patch with artifact().
outputsOnly for a session made with outputs: every output as it now stands, with links that work for 4 days. See Outputs.
outputs_missingPatterns that matched no file this time.

The envelope

Every delivery is one event, as JSON:

JSON
{
  "id": "evt_5b1f0c9e2a7d4e8f9a0b1c2d",
  "object": "event",
  "type": "run.completed",
  "api_version": "v1",
  "created_at": "2026-10-14T04:01:12.381000+00:00",
  "sequence": 6,
  "session": {
    "id": "ags_4843742656c9cbc90685c6d2",
    "status": "idle",
    "metadata": { "customer": "cus_123" },
    "stop_reason": null,
    "error": null,
    "limits": { "max_turns": null, "max_tokens": null, "max_cost_usd": 2.0, "spent": { … } },
    "instance_number": 1,
    "last_seq": 41,
    "schedule_id": null
  },
  "data": {
    "seq": 41,
    "last_message": "Done. The names are sorted in out/sorted.txt.",
    "patches": []
  }
}
FieldMeaning
idThe event's id. The same on every retry, and sent as the webhook-id header. Dedupe on it.
type · api_versionThe event type, and v1.
created_atWhen the event happened.
sequenceCounts up with each event about the same session. Order by it. null for schedule.skipped and webhook.test.
sessionThe session as it was when the event happened: id, status, metadata, stop_reason, error, limits, instance_number, last_seq and schedule_id. null for schedule.skipped and webhook.test.
dataDepends on the type; see Events.

A body is at most 256 KB. If an event would be larger, session.metadata is replaced by { "omitted": true, "reason": "…" }, and if that isn't enough, so is data. Read the session for the full picture. In artifact.ready, a list of more than 200 files is cut short with files_truncated: true.

Verify the signature

Anyone can send a POST to your URL, so check that a delivery came from LatentStack before you act on it. Each one is signed with the endpoint's secret. verifyWebhook (TypeScript) and verify_webhook (Python) check the signature and the time it was sent, and return the event. When a check fails they throw WebhookVerificationError.

A receiver that verifies, answers at once, then does the work
import { createServer } from "node:http"
import { verifyWebhook } from "@latentcode/sandbox-agents"

const secret = process.env.LS_WEBHOOK_SECRET! // whsec_…, shown when you made the endpoint
const seen = new Set<string>() // event ids already handled; keep them in your database in production

createServer(async (req, res) => {
  // The raw body: the signature is over the exact bytes that were sent.
  const chunks: Buffer[] = []
  for await (const chunk of req) chunks.push(chunk)

  let event
  try {
    event = await verifyWebhook(Buffer.concat(chunks), req.headers, secret)
  } catch {
    res.writeHead(400).end()
    return
  }
  res.writeHead(200).end() // answer first, then do the work

  if (seen.has(event.id)) return // a retry carries the same id
  seen.add(event.id)
  if (event.type === "run.completed") console.log(event.session?.id, event.data?.last_message)
}).listen(8000)
  • Pass the body exactly as it arrived, as bytes or a string. A body parsed as JSON and turned back into text won't match the signature.
  • A delivery sent more than 5 minutes from your server's clock is refused, so an old delivery can't be replayed. Change it with toleranceSeconds (TypeScript) or tolerance_seconds (Python), in seconds.
  • Neither helper makes a network call. In Python, verify_webhook is a plain function, so it works the same next to the async client.
  • Run the Python receiver with uvicorn receiver:app --port 8000, and the TypeScript one with npx tsx receiver.ts. Both read the secret from LS_WEBHOOK_SECRET.
POST /hooks/latentstack HTTP/1.1
content-type: application/json
user-agent: latentstack-webhooks/1
webhook-id: evt_5b1f0c9e2a7d4e8f9a0b1c2d
webhook-timestamp: 1791950472
webhook-signature: v1,0eV36p6rL+HHnw+Z+xt0vUONglojAeIaGO0deTCGYn0=

{"id":"evt_5b1f0c9e2a7d4e8f9a0b1c2d","object":"event","type":"run.completed",…}

In other languages

Signatures follow the Standard Webhooks scheme, so any Standard Webhooks library can verify them. To do it yourself:

  1. 01
    Check the time.
    Read webhook-timestamp (Unix seconds). Refuse the delivery if it is more than 5 minutes from now.
  2. 02
    Build the signed text.
    Join webhook-id, webhook-timestamp and the raw body with dots: {id}.{timestamp}.{body}.
  3. 03
    Make the key.
    Remove whsec_ from the start of the secret and base64-decode the rest.
  4. 04
    Sign.
    Compute the HMAC-SHA256 of the signed text with the key, and base64-encode it.
  5. 05
    Compare.
    webhook-signature holds one or more entries such as v1,<signature>, separated by spaces. Accept the delivery if any entry matches yours. Compare in constant time.

Answer fast, then do the work

  • Answer with any 2xx status within 15 seconds. Anything else counts as a failure and is retried: another status, no answer in time, or a redirect (redirects aren't followed).
  • Answer first and do the work afterwards: put the event on a queue, or in a background task as the example does. A handler that takes too long is retried even though it worked.
  • Answer 2xx to events you don't handle too, so they aren't retried.
  • Answer 410 Gone to turn the endpoint off.

Retries

A delivery that fails is tried again after 5 seconds, 30 seconds, 2, 5, 15 and 30 minutes, 1, 2 and 4 hours, then every 8 hours: 18 attempts over about three days. Each wait varies by up to a fifth, so retries from many sessions don't all arrive together. After the last attempt the delivery is failed. The event stays in the event log.

When an endpoint is turned off

  • When it answers 410, or when every delivery to it has failed for three days (at least 10 attempts in a row).
  • Its enabled becomes false and disabled_reason says why: gone or failing. Deliveries waiting for it are marked failed.
  • LatentStack tells you with a notification and an email.
  • Fix the receiver, then turn the endpoint back on with webhooks.update() and enabled: true. Events from while it was off aren't sent again on their own: catch up from the log, or retry the deliveries you need.

last_success_at, last_failure_at and failing_since on the endpoint show how it is doing. Up to 1,000 deliveries can wait for one endpoint. Past that, new events are still logged but not queued for it.

Order and duplicates

  • Each event is delivered at least once. A retry, or an answer that was lost on the way back, can bring the same event twice. Dedupe on id.
  • Events don't always arrive in order: a retried event can arrive after a later one. Within a session, order by sequence. Numbers can skip, so don't expect each one to be the last plus one.
  • session is a snapshot from when the event happened. For the current state, read the session.

Where deliveries can go

  • HTTPS on port 443 only.
  • The host must resolve only to public internet addresses: no localhost and no private networks.
  • No user name or password in the URL. The host is a plain ASCII name without a trailing dot; write an international domain in its xn-- form.
  • The URL is checked when you set it, and again before every attempt.

To try a receiver on your own machine, expose it through an HTTPS tunnel that gives it a public address on port 443.

Delivery logs

Every attempt is recorded. Read what was sent to one endpoint, or what was sent about one session to every endpoint that heard it. Both are newest first and kept for 30 days.

Find failed deliveries
const { data } = await client.agents.webhooks.deliveries(endpoint.id, { status: "failed" })
for (const d of data) console.log(d.event_type, d.attempts, d.last_attempt?.status_code, d.last_attempt?.error)

// What was sent about one session, to every endpoint
const sent = await sessions.webhookDeliveries(session.id)
FieldMeaning
id · event_id · event_typeThe delivery (whd_…), and the event it carries.
endpoint_id · session_idWhere it went, and the session it is about (null for a test or a skipped schedule).
statuspending until a 2xx (succeeded), or failed: the retries ran out, the endpoint answered 410, or it was turned off or deleted.
attempts · next_attempt_atHow many attempts so far, and when the next one is due while pending.
last_attemptat, status_code (null when no answer came), latency_ms, response (the first 512 bytes of your answer) and error.

Test, rotate and retry

const webhooks = client.agents.webhooks

// Send a webhook.test event now
const delivery = await webhooks.test(endpoint.id)

// Make a new secret. The old one keeps working for 24 hours.
const { secret } = await webhooks.rotateSecret(endpoint.id)

// Send a delivery again now, e.g. a failed one from the delivery log
await webhooks.retryDelivery(endpoint.id, deliveryId)

Test

A test sends a webhook.test event to that endpoint, whatever it subscribes to. The answer is the delivery; read it again from the delivery log to see how it went. A test is tried 3 times at most. Test events have no session and aren't in the event log.

Rotate the secret

Rotating makes a new secret and shows it once. For the next 24 hours, each delivery carries two signatures, one for each secret, so a receiver that knows either one accepts it. Give your receiver the new secret within that time.

Retry

A retry puts a delivery back in the queue for an attempt right away, whatever its status. It fails with conflict while that delivery is being attempted.

Tests and retries need the endpoint to be on; otherwise they fail with endpoint_disabled. Together they are limited to 60 a minute per person (rate_limited). A test fails with endpoint_backlogged when 1,000 deliveries are already waiting for the endpoint.

Catch up from the event log

Every event is also kept in a log for 30 days, whether or not an endpoint received it. After your receiver was down, or turned off, read the log from the last event you handled.

Handle everything since the last event you saw
let after = lastHandledId // the id of the last event you handled, from your database
while (true) {
  const page = await client.agents.events.list({ after, limit: 100 })
  for (const event of page.data) await handle(event) // the same handler, deduping on id
  if (!page.has_more) break
  after = page.next_after!
}
  • Events come oldest first, with the same body a delivery has, for your sessions and schedules in the organization you're working in.
  • Filter with types (a comma-separated list; families and * work) and session_id.
  • An event appears in the log a few seconds after it happens. Test events aren't listed.
  • after must be an event still in the log. If your last id is more than 30 days old, start again without it.

Over HTTP

RequestDoes
POST /v1/agents/webhooksRegisters an endpoint. Answers 201 with its secret.
GET /v1/agents/webhooks?limit=&after=Lists your registered endpoints, newest first.
GET /v1/agents/webhooks/{id}Reads one endpoint, registered or given with a session or schedule.
PATCH /v1/agents/webhooks/{id}Changes url, events, description or enabled.
DELETE /v1/agents/webhooks/{id}Deletes it. Answers 204.
POST /v1/agents/webhooks/{id}/rotateMakes a new secret. Answers with the endpoint and its new secret.
POST /v1/agents/webhooks/{id}/testSends a test event. Answers 202 with the delivery.
GET /v1/agents/webhooks/{id}/deliveries?status=&limit=&after=What was sent to it. status is pending, succeeded or failed.
POST /v1/agents/webhooks/{id}/deliveries/{delivery_id}/retrySends a delivery again now. Answers 202.
GET /v1/agents/sessions/{id}/webhooks?limit=&after=What was sent about one session.
GET /v1/agents/events?types=&session_id=&after=&limit=The event log, oldest first.

Lists hold 20 items by default (the event log 50), and up to 100. When has_more is true, pass next_after as after for the next page.

Note
Turned on per deployment
Your administrator turns webhooks on for a deployment. Where they're off, these requests answer 404 and a webhook on create is refused with invalid_request.