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 endpoint | On one session | |
|---|---|---|
| Made with | webhooks.create() | The webhook field when you create a session |
| Hears | Every session you create in the organization, and your schedules' skipped runs | That session only |
| Secret | Made for you, shown once | Yours, or made for you and shown once |
| Lasts | Until you delete it | 7 days after the session ends |
| How many | 10 per person in each organization, unless your administrator set another number | Not counted |
A schedule can have a webhook of its own, which hears every session it starts. See Schedules.
Register an endpoint
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 iturlstringrequiredeventslistrequiredrun.completed, a family such as run.* or session.*, or * for everything. Up to 50 entries.descriptionstringoptionalenabledbooleanoptionaldefault true- 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,descriptionorenabledlater withwebhooks.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 noweventsdefaults to["*"].- The create answer has a
webhookfield: the endpoint, withsession_idset. It has asecretonly 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 iswhsec_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)"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
| type | Sent when | data |
|---|---|---|
| session.ready | A sandbox is ready: when the session starts, and each time it resumes. | instance_number, instance_context |
| session.paused | The 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.stopped | The session ended for good. | stop_reason |
| session.failed | The session couldn't start, or failed for good. | error |
| run.started | The agent picked up a message. | seq |
| run.completed | A 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.failed | A run stopped on an error. The session is still usable. | seq, error, patches, and outputs |
| approval.requested | The agent is waiting for your permission. See Approvals. | request_id, action, resources, metadata |
| question.requested | The agent asked you a question. | request_id, questions |
| limit.warning | 80% of a spend cap is used. Once per cap. | limit, max, spent |
| limit.reached | A spend cap was reached. The agent wraps up, then the session pauses until the cap is raised. | limit, max, spent |
| artifact.ready | A 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.skipped | A schedule's time came and no session was started. See Schedules. | schedule_id, due_at, trigger, reason, message |
| webhook.test | You 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
| Field | Meaning |
|---|---|
| seq | The number of the run's last event on the session's event stream. |
| last_message | The agent's last reply. Cut to 8 KB, with last_message_truncated: true. null when there is none. |
| patches | One per repository: repo, version, size, files_changed, final. Download the patch with artifact(). |
| outputs | Only for a session made with outputs: every output as it now stands, with links that work for 4 days. See Outputs. |
| outputs_missing | Patterns that matched no file this time. |
The envelope
Every delivery is one event, as 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": []
}
}| Field | Meaning |
|---|---|
| id | The event's id. The same on every retry, and sent as the webhook-id header. Dedupe on it. |
| type · api_version | The event type, and v1. |
| created_at | When the event happened. |
| sequence | Counts up with each event about the same session. Order by it. null for schedule.skipped and webhook.test. |
| session | The 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. |
| data | Depends 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.
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) ortolerance_seconds(Python), in seconds. - Neither helper makes a network call. In Python,
verify_webhookis 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 withnpx tsx receiver.ts. Both read the secret fromLS_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:
- 01Check the time.Read
webhook-timestamp(Unix seconds). Refuse the delivery if it is more than 5 minutes from now. - 02Build the signed text.Join
webhook-id,webhook-timestampand the raw body with dots:{id}.{timestamp}.{body}. - 03Make the key.Remove
whsec_from the start of the secret and base64-decode the rest. - 04Sign.Compute the HMAC-SHA256 of the signed text with the key, and base64-encode it.
- 05Compare.
webhook-signatureholds one or more entries such asv1,<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 Goneto 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
enabledbecomesfalseanddisabled_reasonsays why:goneorfailing. 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()andenabled: 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. sessionis 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
localhostand 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.
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)| Field | Meaning |
|---|---|
| id · event_id · event_type | The delivery (whd_…), and the event it carries. |
| endpoint_id · session_id | Where it went, and the session it is about (null for a test or a skipped schedule). |
| status | pending until a 2xx (succeeded), or failed: the retries ran out, the endpoint answered 410, or it was turned off or deleted. |
| attempts · next_attempt_at | How many attempts so far, and when the next one is due while pending. |
| last_attempt | at, 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.
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) andsession_id. - An event appears in the log a few seconds after it happens. Test events aren't listed.
aftermust be an event still in the log. If your last id is more than 30 days old, start again without it.
Over HTTP
| Request | Does |
|---|---|
| POST /v1/agents/webhooks | Registers 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}/rotate | Makes a new secret. Answers with the endpoint and its new secret. |
| POST /v1/agents/webhooks/{id}/test | Sends 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}/retry | Sends 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.
webhook on create is refused with invalid_request.