Webhooks
Be told when a sandbox is made, starts, stops, fails or is deleted, and when mail arrives for one.
For your agent
agental.io/docs/webhooks.mdcurl -X POST https://agental.io/v1/webhooks \
-H "Authorization: Bearer $AGENTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/agental","events":["sandbox.created","sandbox.error"]}'- The answer includes
signingSecret, once. Keep it. eventsnames what to send, from the table below, or*for all of them. Leave it out to get all of them.urlstarts withhttp://orhttps://and is on the public internet. Redirects are not followed.
Events
| Event | Sent when |
|---|---|
sandbox.created | A new sandbox runs for the first time. |
sandbox.started | A sandbox runs again after it was stopped: a start you asked for, one from storage, or the start that follows a move to another server. resumed is true when its stop kept its memory and the start put it back. |
sandbox.stopped | It stops. reason says why: asked, ttl when its time limit stopped it, billing when the plan did, or move when it was stopped to be moved to another server and is started again there. memoryKept is true when its memory was kept. |
sandbox.archived | Its disk was moved to storage. It is still stopped. |
sandbox.moved | Its machine was moved to another server. restarted says whether what ran in it was lost. |
sandbox.error | It went to error. error says why. |
sandbox.deleted | It was deleted. operationId is the deletion. reason is schedule when a schedule that runs once deleted it after its run. |
sandbox.auto_stop_set | Its stop was moved. autoStopAt is when it stops now, previousAutoStopAt when it would have, and by is account or sandbox, who moved it. |
sandbox.labels_changed | Its own tags or metadata changed, or it was put on another template. tags and metadata are what it shows now, previousTags and previousMetadata what it showed before. by is sandbox, or template with templateId for a move to another template. |
template.created | A template was made (an account's default is made without one). It carries its start, tags and metadata. Every template.* event has templateId, name, and default, true for the account's default template, and is sent once, to the team's webhooks: the template's sandboxes are sent nothing. |
template.updated | A template's name, start (size, snapshot, time limit), network rule or SSH (turned on or off, a key added or removed) changed. changed lists which, of name, start, network and ssh, and each one listed is there with what it is now. A change to the same thing again sends nothing. What a sandbox already made has is not changed: it takes a network rule or secrets when it next starts or is applied to. |
template.secrets_changed | One of a template's variables or secret files was set, had its hosts changed, or was removed. change is set, hosts_changed or removed; variable has its name, and when it is set, hidden and hosts, or file is its path. variables, hidden and files name what it has now. No value is ever in it. A sandbox's own secrets send nothing. |
template.labels_changed | A template's own tags or metadata changed: tagsAdded, tagsRemoved, metadataSet and metadataRemoved say how, and tags and metadata are what it has now. default is true for the account's default template, whose sandboxes include the ones made naming none (their templateId is null). It is sent once, to the team's webhooks, and its sandboxes are sent nothing: they show the template's from then on. To keep your own record of them, read the sandboxes on it again, or merge the change in. |
template.deleted | A template was deleted. name is the name it had. |
prompt.created | A prompt was given to it. |
What is sent
{
"id": "evt_9f2c41d7a05b6e38",
"type": "sandbox.stopped",
"createdAt": "2026-10-09T15:45:00.412Z",
"data": {
"sandboxId": "sbx_2a9a47e50daec64a",
"name": "build-agent",
"status": "stopped",
"previousStatus": "running",
"tags": ["ci", "pr-412"],
"metadata": { "repo": "acme/web" },
"stopId": "stp_0b7d2e9a41c6f853",
"reason": "ttl"
}
}- Every
sandbox.event has the sandbox'ssandboxIdandname, itsstatusnow and itspreviousStatus, and itstagsandmetadataas they were then, its template's with its own. idis the same in every webhook the event is sent to, and in the log record your collector is sent. Use it to tell one event from another.- An event is written with the change it is about, and is sent once that change is made. A stop answers after its webhooks were sent.
- Each webhook is sent each event until it answers with a 2xx, waiting 2 seconds for each answer: see below.
GET /v1/sandboxes/{id}/eventslists every event a sandbox had.
Email events
| Event | Sent when | What `data` carries |
|---|---|---|
email.received | A message for a sandbox was taken and kept. | messageId, sandboxId, to, from, subject, sizeBytes, attachments, checks, spamScore, sentByProgram, and url, where the message is read. |
email.delivered | The recipient's server took a message a sandbox sent. | messageId, sandboxId, to. |
email.bounced | A message a sandbox sent was refused, or could not be delivered within a day. | messageId, sandboxId, to, reason, which is refused or expired, and detail, which is what a server said. |
They are sent whether the sandbox is running or not. See Email.
Check the signature
Each request carries X-Agental-Signature: sha256=<hex>: the HMAC-SHA256 of the raw body, keyed with your signing secret. Work it out yourself and compare before you trust the body. POST /v1/webhooks/{id}/rotate gives a new secret, once.
The SDKs do the comparison. Pass the body exactly as it arrived, before any JSON parsing:
import { verifyWebhook } from "agental-sdk";
const genuine = await verifyWebhook(rawBody, request.headers.get("x-agental-signature"), signingSecret);When your server does not answer
- An event counts as taken when your server answers
2xx. Anything else, or no answer in time, and it is tried again. - An event is tried eight times over about a day: at once, then after 1, 5 and 15 minutes, and after 1, 3, 6 and 12 hours.
- Every try carries the same
id, so a receiver that has taken an event can ignore it when it comes again. GET /v1/webhooks/{id}lists the webhook's recentdeliveries, with the status your server answered. Each one is a try, and says which event it was a try of aseventId, and which try asattempt. Tries are kept for 30 days.
In the console
- The Webhooks tab lists your webhooks beside the kinds of event, wired to the ones each is sent. Point at a webhook to light what it takes, or at a kind to light the webhooks that take it. A failing webhook's wires are dashed.
- Add a webhook ticks the kinds on the same drawing. The signing secret is shown once, on the webhook's page that opens next, with how to check it.
- A webhook's Deliveries draws its last 24 hours, a line for each kind, and each event it did not take at the first try on the schedule of eight tries, with the answer to every try and when the next is due.
- Its Settings change the address, the description and the kinds, make a new secret and delete it, and each of the last two asks first.
- A sandbox's Email tab says so when none of your webhooks takes
email.received.
From the CLI
agental webhooks agental webhook add https://example.com/hooks/agental --events sandbox.stopped,sandbox.error --description "build bot" agental webhook show <webhook-id> agental webhook update <webhook-id> --all agental webhook rotate <webhook-id> agental webhook delete <webhook-id>
add prints the signing secret, once. --all is every event, those added later included. show lists each event the webhook did not take at the first try, with the answer to every try.
The full machine-readable reference is OpenAPI 3.1. Every page as Markdown, for an agent: llms.txt. Questions: [email protected]