moatbox records an event when a space changes state, a run starts or ends, or an agent's action waits for approval. You can get those events three ways:
- read them from the events API,
- have moatbox POST each one to your webhook,
- export them, with metrics and run traces, to an OpenTelemetry collector.
All three read the same event records, so an event carries the same id everywhere.
The event object
{
"id": "evt_6f1c0a7e2b8d4c55a0e3d1f2b4c6a8e0",
"type": "space.ready",
"seq": 1042,
"timestamp": "2026-11-08T10:00:00.123456Z",
"team": {"id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427"},
"space": {"id": "9f3c2e1a-4b5d-4c6e-8f70-1a2b3c4d5e6f", "name": "web"},
"data": {"previous_state": "sleeping", "woke_in_ms": 2140}
}
| Field | Meaning |
|---|---|
id |
evt_ plus 32 hex characters. It stays the same across retries and redeliveries; use it to drop duplicates. |
type |
One of the types below. |
seq |
A number that grows with each event. Deliveries can arrive out of order; sort by seq. |
timestamp |
When the event happened, UTC. |
team |
The team's id. |
space |
The space's id and name (its slug), or null for a team event. |
data |
Fields that depend on the type. |
Event types
| Type | When | data |
|---|---|---|
space.created |
A space was created, forked, made from a snapshot, or cloned from a share code. | from (new, fork, snapshot, share), parent, size |
space.ready |
A space is up: after create, after waking, or after an operator resumed it. | previous_state, woke_in_ms after a wake, reason: sleep_failed when a sleep was rolled back |
space.sleeping |
A space went to sleep: idle, by hand, its awake limit, the spend cap, or a wallet that ran out. Disk is kept, and memory unless the snapshot failed. | previous_state |
space.stopped |
An operator suspended a space; it cannot wake until resumed. | previous_state, reason |
space.error |
A space stopped answering health checks (reason: not_responding) or could not sleep (reason: sleep_failed). |
reason, since, error |
space.recovered |
A space answers again after space.error. |
down_seconds |
space.deleted |
A space moved to the trash. You can restore it until purge_at. |
previous_state, purge_at, by |
space.restored |
A space came back from the trash. | state, by |
snapshot.saved |
Someone saved a named snapshot. | snapshot_id, name, by |
space.disk_low |
The disk crossed 85%. | used, total (bytes), percent |
space.disk_full |
The disk crossed 95%. Writes, sleep and fork can fail. | used, total, percent |
team.spend_cap_reached |
Spend beyond the plan reached the cap; every space sleeps until the next period or a higher cap. space is null. |
cap_cents, period_start |
space.ai_cap_reached |
A space or team AI spend cap (per UTC day or month) was reached; the AI gateway refuses that space's model requests. | scope (space, team), window (day, month), cap_usd, spent_usd, message |
space.ai_key_refused |
A model provider refused an AI profile's key (HTTP 401 or 403). | provider, account, profile, status, message |
run.started |
An agent run, or an interactive SSH session (shell, file transfer, port forward), started. One-off SSH commands appear only in the audit log. | run_id, agent |
run.finished |
A run ended with exit code 0. | run_id, exit |
run.failed |
A run ended with a non-zero exit code or an error. | run_id, error, and exit when the run exited |
approval.requested |
The gateway held a risky action for a person to approve. | run_id, entry_id, action |
approval.decided |
A held action was approved, denied, undone or abandoned. | run_id, entry_id, action, decision, by |
webhook.test |
Only from the test button or the test route. It never appears in the event stream. | message, webhook, sent_by |
GET /api/v1/teams/{t}/webhooks returns this catalogue, minus webhook.test, as event_types, with an example data for each.
Read events from the API
curl "https://app.moatbox.dev/api/v1/teams/<team-id>/events?after=1000&n=100" \
-H "Authorization: Bearer mbx_…"
The route needs team.read and returns {"events": [...]}.
| Query | Meaning |
|---|---|
after |
Only events with seq above this, oldest first. Use it to page forward. |
before |
Only events with seq below this. |
type |
One event type. An unknown type gets 400. |
space |
One space, by slug. |
n |
How many, 1 to 200. Defaults to 50. |
Without after, the newest events come first.
A key limited to spaces or environments cannot read team routes, this one included.
Webhooks
Who can manage them
Team owners and admins manage webhooks, in the dashboard or with an API key that holds webhook.manage. Reading webhooks and their deliveries also works with team.read. The key's creator must be an owner or admin either way. A team can have at most 10 webhooks.
Create a webhook
curl -X POST https://app.moatbox.dev/api/v1/teams/<team-id>/webhooks \
-H "Authorization: Bearer mbx_…" \
-H "Content-Type: application/json" \
-d '{"name": "ops", "url": "https://hooks.example.com/moatbox", "events": ["run.failed", "approval.requested"]}'
name: one line, 1 to 80 characters.url:https://only, at most 2000 characters, no user, password or#fragment. At save time, every address the host resolves to must be public: moatbox refuses private, loopback, link-local, carrier-grade NAT, documentation and NAT64 ranges. On each connection moatbox resolves the host again and connects only to a public address.events: at least one event type.
The 201 response holds webhook and secret. moatbox shows the secret only here. It looks like whsec_ followed by base64 of 32 random bytes.
PATCH /api/v1/teams/{t}/webhooks/{id} takes any of name, url, events and enabled. The secret stays the same.
What moatbox sends
Each delivery is a POST with the event object as a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
moatbox-webhooks/1 |
webhook-id |
The event's id |
webhook-timestamp |
Unix seconds when moatbox made this attempt |
webhook-signature |
v1,<signature>, space-separated when two secrets are active |
Moatbox-Event-Type |
The event's type |
Moatbox-Attempt |
The attempt number, from 1 |
Verify the signature
The signature is HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of the secret after whsec_, then base64-encoded.
Check it against the raw request body, before any JSON parsing. Reject old timestamps to block replays; pick a window that suits you, such as 5 minutes.
import crypto from "node:crypto";
export function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
return headers["webhook-signature"].split(" ").some((sig) => {
const [version, value] = sig.split(",");
if (version !== "v1" || !value) return false;
const got = Buffer.from(value, "base64");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
for sig in headers["webhook-signature"].split(" "):
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(base64.b64decode(value), expected):
return True
return False
Rotate the secret
curl -X POST https://app.moatbox.dev/api/v1/teams/<team-id>/webhooks/<webhook-id>/rotate \
-H "Authorization: Bearer mbx_…" \
-H "Content-Type: application/json" \
-d '{"overlap_hours": 24}'
The response holds the new secret and previous_valid_until. During the overlap (0 to 72 hours, 24 by default) moatbox signs each delivery with both secrets and sends both signatures in webhook-signature. Deploy the new secret to your receiver before the overlap ends. With overlap_hours: 0 the old secret stops at once.
Delivery and retries
For each event, moatbox queues a delivery to every webhook that is on and subscribed to its type. A delivery can arrive more than once; drop duplicates by id. A delivery succeeds when your endpoint answers 2xx within 10 seconds. moatbox does not follow redirects; a 3xx counts as a failure.
On failure moatbox retries up to 11 more times, 12 attempts in all. The waits between attempts are 30 s, 2 min, 5 min, 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 5 h and 8 h, each varied by up to 10%. The last attempt comes about 24 hours after the first. moatbox sends up to 16 deliveries at once, so events can arrive out of order.
moatbox turns the webhook off at the first failed attempt after 24 hours in which every delivery to the endpoint failed, and emails the team's owners and admins. Pending deliveries wait, and moatbox sends them as soon as you turn the webhook back on (PATCH with {"enabled": true}). moatbox queues nothing for events that happen while the webhook is off. A delivery that used all 12 attempts stays failed; send it again with redeliver.
The webhook object reports health: new, ok, failing or disabled, with failing_since, last_success_at, last_failure_at, delivered_24h, failed_24h and pending.
Delivery log, redelivery and tests
moatbox keeps delivery records for 30 days.
| Method | Path | Action | What it does |
|---|---|---|---|
| GET | /api/v1/teams/{t}/webhooks |
team.read or webhook.manage |
List webhooks, the limit, and event_types |
| POST | /api/v1/teams/{t}/webhooks |
webhook.manage |
Create one |
| GET | /api/v1/teams/{t}/webhooks/{id} |
team.read or webhook.manage |
Read one |
| PATCH | /api/v1/teams/{t}/webhooks/{id} |
webhook.manage |
Change it |
| DELETE | /api/v1/teams/{t}/webhooks/{id} |
webhook.manage |
Delete it |
| POST | /api/v1/teams/{t}/webhooks/{id}/rotate |
webhook.manage |
New secret |
| POST | /api/v1/teams/{t}/webhooks/{id}/test |
webhook.manage |
Send webhook.test now |
| GET | /api/v1/teams/{t}/webhooks/{id}/deliveries |
team.read or webhook.manage |
The delivery log |
| GET | /api/v1/teams/{t}/webhooks/{id}/deliveries/{d} |
team.read or webhook.manage |
One delivery with every attempt |
| POST | /api/v1/teams/{t}/webhooks/{id}/deliveries/{d}/redeliver |
webhook.manage |
One more attempt now |
- The log takes
?state=(pending,deliveredorfailed),?before=<delivery-id>and?n=(1 to 200, default 50). - One delivery returns its exact
payloadand itsattempts, each withn,at,statusorerror,ms, the first 1 KiB of yourresponse, andmanual. - A test sends one attempt and no retries. It returns
delivery_idandattempt. - A redelivery makes one attempt and returns
attempt. When it succeeds, moatbox marks the delivery delivered.
OpenTelemetry export
moatbox can push metrics, events and run traces to your OpenTelemetry collector over OTLP/HTTP with the JSON encoding. A team can have up to 3 endpoints. The same people and key actions that manage webhooks manage telemetry endpoints.
curl -X POST https://app.moatbox.dev/api/v1/teams/<team-id>/telemetry \
-H "Authorization: Bearer mbx_…" \
-H "Content-Type: application/json" \
-d '{"name": "collector", "url": "https://otlp.example.com/otlp", "headers": {"Authorization": "Basic …"}}'
urlis the collector's base URL. moatbox adds/v1/metrics,/v1/logsand/v1/traces, and refuses a URL that already ends in one of them. The public-address rules for webhooks apply here too.headersholds up to 20 headers. Each value is one line of at most 4096 bytes. moatbox refusesHost,Content-Type,Content-Length,Content-Encoding,Transfer-Encoding,ConnectionandUser-Agent. moatbox encrypts the values and never returns them; the endpoint lists only header names.- Saving runs a test export (an empty
/v1/logsrequest) and returnsendpointandtest(ok,status,message). - A new endpoint receives events from the moment you add it, not older ones.
| Method | Path | Action |
|---|---|---|
| GET | /api/v1/teams/{t}/telemetry |
team.read or webhook.manage |
| POST | /api/v1/teams/{t}/telemetry |
webhook.manage |
| PATCH | /api/v1/teams/{t}/telemetry/{id} |
webhook.manage |
| DELETE | /api/v1/teams/{t}/telemetry/{id} |
webhook.manage |
| POST | /api/v1/teams/{t}/telemetry/{id}/test |
webhook.manage |
PATCH takes any of name, url, headers and enabled. A headers object replaces the saved set, so list every header you want to keep; send null as a value to keep that header's saved value. The list shows last_export_at, last_error, last_error_at and retry_at per endpoint.
Metrics
Once a minute, moatbox sends gauges for each space with monitoring on:
| Metric | Unit |
|---|---|
moatbox.space.cpu.utilization |
1 |
moatbox.space.memory.usage |
By |
moatbox.space.memory.utilization |
1 |
moatbox.space.filesystem.usage |
By |
moatbox.space.filesystem.utilization |
1 |
moatbox.space.io.read |
By/s |
moatbox.space.io.write |
By/s |
moatbox.team.spaces.active |
{space} |
Resource attributes: service.name=moatbox, moatbox.team.id, and for a space service.instance.id, moatbox.space.id, moatbox.space.name and moatbox.space.size. After an outage moatbox resends up to 1 hour of metrics, in time order.
Events as logs
Each event becomes a log record with eventName set to the event type and these attributes: event.name, moatbox.event.id, moatbox.event.seq, moatbox.data.<key> for each data field, and for a space event moatbox.space.id and moatbox.space.name.
| Severity | Types |
|---|---|
ERROR (17) |
space.error, run.failed |
WARN (13) |
space.disk_low, space.disk_full, team.spend_cap_reached, space.stopped, space.ai_cap_reached, space.ai_key_refused |
INFO (9) |
Every other type |
Runs as traces
When a run ends, moatbox exports it as one trace of at most 1000 spans:
- a root span
run <agent>withmoatbox.run.id,moatbox.run.agentandmoatbox.run.exit, plusgen_ai.request.model,gen_ai.usage.input_tokensandgen_ai.usage.output_tokenswhen the agent reported them, - a child span
tool <name>per tool call the agent reported, withgen_ai.tool.nameandmoatbox.tool.target, - a span
approval <action>per held action, from held to decided, withmoatbox.approval.entry_id,moatbox.approval.decisionandmoatbox.approval.by, - a zero-length span per gateway action, with
moatbox.gateway.actionandmoatbox.gateway.summary.
If moatbox cannot read the run's timeline, the run gets no trace.
Under zero data retention (see Privacy) the spans keep names, times and outcomes but drop moatbox.tool.target and moatbox.gateway.summary.
Retries
moatbox retries an export that gets 429, 502, 503, 504 or no answer. It waits as long as Retry-After asks, 30 seconds when the header is missing, and at most 10 minutes. Other errors land in last_error and moatbox moves on.