> ## Documentation Index
> Fetch the complete documentation index at: https://docs.anonalabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks and telemetry export

> Signed callbacks for sandbox lifecycle events, and sandbox metrics in your own OpenTelemetry backend.

## Webhooks

Anona Compute sends an HTTPS `POST` to your endpoint when a sandbox is created, paused, resumed, deleted or runs into trouble. Set endpoints up on the dashboard's **Webhooks** page, or with the API.

### Events

| Type | When |
| - | - |
| `sandbox.lifecycle.created` | Create finished; the sandbox runs |
| `sandbox.lifecycle.paused` | Paused, memory and disk kept |
| `sandbox.lifecycle.resumed` | Resumed from pause |
| `sandbox.lifecycle.killed` | Deleted, by request or at its timeout |
| `sandbox.lifecycle.updated` | Its timeout changed |
| `sandbox.error` | Create failed, or the sandbox or its host was lost |
| `sandbox.degraded` | Its host is being evacuated |
| `sandbox.recovered` | Running again after an evacuation |
| `webhook.test` | Only from **Send test event** |

The body follows E2B's `SandboxEvent` shape:

```json theme={null}
{
  "id": "6a0d5b0e-3f0c-4f6e-9a55-1b2f0c7d9e11",
  "version": "v1",
  "type": "sandbox.lifecycle.paused",
  "timestamp": "2026-10-28T12:00:04Z",
  "sandboxId": "i3f9a...",
  "sandboxExecutionId": "...",
  "sandboxTemplateId": "base",
  "sandboxBuildId": "",
  "sandboxTeamId": "...",
  "eventData": { "cpuCount": 2, "memoryMB": 2048, "timeoutSeconds": 300 }
}
```

`eventData` can also carry `endReason` (`killed`, `timeout`, `failed`, `lost`) and `reason`.

### Manage endpoints with the API

Owners and admins manage webhooks in the dashboard. For automation, create a **custom** API key with the `integrations:manage` scope; no preset includes it.

```python webhooks_api.py theme={null}
import os
import httpx

api = httpx.Client(base_url="https://api.compute.anonalabs.com", headers={"X-API-Key": os.environ["E2B_API_KEY"]})

r = api.post("/v1/webhooks", json={
    "name": "lifecycle to my app",
    "url": "https://example.com/anona/webhooks",
    "events": ["sandbox.lifecycle.created", "sandbox.lifecycle.killed"],
})
r.raise_for_status()
hook, secret = r.json()["webhook"], r.json()["secret"]  # secret: whsec_..., shown once
print(hook["id"], hook["events"], secret[:6] + "...")

api.post(f"/v1/webhooks/{hook['id']}/test").raise_for_status()        # queue a webhook.test event
print([d["eventType"] for d in api.get(f"/v1/webhooks/{hook['id']}/deliveries").json()["deliveries"]])
api.patch(f"/v1/webhooks/{hook['id']}", json={"enabled": False}).raise_for_status()
api.delete(f"/v1/webhooks/{hook['id']}").raise_for_status()
```

URLs must be `https://`, with a public host name: private, loopback and cloud-metadata addresses are refused when you save the URL, and again at every send (a name that later resolves inside is blocked). Redirects are never followed. Up to 10 endpoints per organization.

### Delivery

* **At least once.** Deduplicate on the `webhook-id` header (the event ID; the same on every retry and every endpoint).
* **No ordering.** Use `timestamp`.
* **Success** is any `2xx` within 10 seconds; anything else, including a redirect, is a failed attempt.
* **Retries** back off exponentially from 30 seconds up to 2 hours between attempts. After 10 attempts (about 4 hours) a delivery is dead. Resend it from the dashboard or with `POST /v1/webhooks/{id}/deliveries/{deliveryId}/resend`.
* Events and the delivery log are kept for 30 days.

### Verify the signature

Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com), so its libraries (`standardwebhooks` on PyPI and npm, and others) verify them. By hand:

| Header | Value |
| - | - |
| `webhook-id` | The event ID |
| `webhook-timestamp` | Unix seconds when this attempt was signed |
| `webhook-signature` | `v1,<base64 HMAC-SHA256(key, id + "." + timestamp + "." + body)>`, where `key` is the base64 part of your `whsec_` secret |

Verify the **raw** body before parsing it, reject timestamps more than 5 minutes off, compare in constant time, and deduplicate on `webhook-id`. For 24 hours after you rotate a secret, the header carries two signatures separated by a space; accept the request if either matches.

```python receiver.py theme={null}
import base64, hashlib, hmac, json, os, time
from flask import Flask, request, abort

SECRET = os.environ["ANONA_WEBHOOK_SECRET"]  # whsec_...
KEY = base64.b64decode(SECRET.removeprefix("whsec_"))
app = Flask(__name__)

@app.post("/anona/webhooks")
def hook():
    msg_id = request.headers.get("webhook-id", "")
    ts = request.headers.get("webhook-timestamp", "0")
    body = request.get_data()  # raw bytes
    if not msg_id or abs(time.time() - int(ts)) > 300:
        abort(400)
    mac = hmac.new(KEY, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    want = "v1," + base64.b64encode(mac).decode()
    if not any(hmac.compare_digest(s, want) for s in request.headers.get("webhook-signature", "").split(" ")):
        abort(401)
    event = json.loads(body)  # deduplicate on event["id"]
    print(event["type"], event["sandboxId"])
    return "", 200
```

```js receiver.mjs theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const secret = process.env.ANONA_WEBHOOK_SECRET; // whsec_...
const app = express();
app.post("/anona/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id");
  const ts = req.header("webhook-timestamp");
  const sigs = (req.header("webhook-signature") ?? "").split(" ");
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const want = "v1," + createHmac("sha256", key).update(`${id}.${ts}.`).update(req.body).digest("base64");
  const ok = sigs.some((s) => s.length === want.length && timingSafeEqual(Buffer.from(s), Buffer.from(want)));
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body); // deduplicate on event.id
  console.log(event.type, event.sandboxId);
  res.sendStatus(200);
});
app.listen(8080);
```

Rotate a secret with **Rotate secret** in the dashboard or `POST /v1/webhooks/{id}/rotate-secret`; the old secret keeps signing for 24 hours.

## OpenTelemetry export

Send every sandbox's metrics and lifecycle spans to your own OTLP/HTTP backend (Grafana, Honeycomb, Datadog, an OpenTelemetry Collector, ...). Set it on the dashboard's **Telemetry** page, or:

```bash otel_export.sh theme={null}
curl -sS -X PUT https://api.compute.anonalabs.com/v1/otel-export \
  -H "X-API-Key: $E2B_API_KEY" -H 'Content-Type: application/json' \
  -d '{"endpoint": "https://example.com/otlp", "headers": {"Authorization": "Bearer my-backend-token"}, "enabled": true}' \
  | jq '{endpoint: .export.endpoint, headers: .export.headerNames, every: .intervalSeconds}'
curl -sS -X DELETE https://api.compute.anonalabs.com/v1/otel-export -H "X-API-Key: $E2B_API_KEY" -o /dev/null -w '%{http_code}\n'
```

Payloads (protobuf) go to `<endpoint>/v1/metrics` and `<endpoint>/v1/traces` every 60 seconds. Header values are encrypted at rest and never returned; leave `headers` out of an update to keep them.

| Metric | Type | Unit |
| - | - | - |
| `anona.sandbox.cpu.utilization` | Gauge, per minute | fraction of the sandbox's vCPUs |
| `anona.sandbox.cpu.time` | Sum, delta | s |
| `anona.sandbox.memory.usage` | Gauge (mean resident over the minute) | By |
| `anona.sandbox.memory.limit` | Gauge | By |
| `anona.sandbox.vcpus` | Gauge | `{vcpu}` |
| `anona.sandbox.network.io` | Sum, delta, `network.io.direction` = `transmit` / `receive` | By |

Resource attributes: `service.name=anona-sandbox`, `anona.tenant.id`, `anona.sandbox.id`, `anona.template.id`. Each lifecycle event is a span from the sandbox's previous event to this one; all spans of a sandbox share one trace. `sandbox.error` spans have status ERROR. If your endpoint is down, metrics older than an hour and events older than a day are dropped rather than replayed; the dashboard shows the last error.

The same access rules apply as for webhooks: owners and admins, or a custom key with `integrations:manage`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.