# Webhooks

Be told when a sandbox is made, starts, stops, fails or is deleted, and when mail arrives for one.

`POST /v1/webhooks`

```shell
curl -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.
- `events` names what to send, from the table below, or `*` for all of them. Leave it out to get all of them.
- `url` starts with `http://` or `https://` 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](https://agental.io/docs/tags) 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

```json
{
  "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's `sandboxId` and `name`, its `status` now and its `previousStatus`, and its `tags` and `metadata` as they were then, its template's with its own.
- `id` is the same in every webhook the event is sent to, and in the log record [your collector](https://agental.io/docs/opentelemetry) 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}/events` lists 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](https://agental.io/docs/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:

```typescript
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 recent `deliveries`, with the status your server answered. Each one is a try, and says which event it was a try of as `eventId`, and which try as `attempt`. 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

```bash
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.

---

Every page of these docs as Markdown: [llms.txt](https://agental.io/llms.txt)
