Skip to content
All pages

Events, webhooks and telemetry

Read moatbox events over the API, receive them as signed webhooks, and export metrics, logs and run traces to an OpenTelemetry collector.

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, delivered or failed), ?before=<delivery-id> and ?n= (1 to 200, default 50).
  • One delivery returns its exact payload and its attempts, each with n, at, status or error, ms, the first 1 KiB of your response, and manual.
  • A test sends one attempt and no retries. It returns delivery_id and attempt.
  • 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 …"}}'
  • url is the collector's base URL. moatbox adds /v1/metrics, /v1/logs and /v1/traces, and refuses a URL that already ends in one of them. The public-address rules for webhooks apply here too.
  • headers holds up to 20 headers. Each value is one line of at most 4096 bytes. moatbox refuses Host, Content-Type, Content-Length, Content-Encoding, Transfer-Encoding, Connection and User-Agent. moatbox encrypts the values and never returns them; the endpoint lists only header names.
  • Saving runs a test export (an empty /v1/logs request) and returns endpoint and test (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> with moatbox.run.id, moatbox.run.agent and moatbox.run.exit, plus gen_ai.request.model, gen_ai.usage.input_tokens and gen_ai.usage.output_tokens when the agent reported them,
  • a child span tool <name> per tool call the agent reported, with gen_ai.tool.name and moatbox.tool.target,
  • a span approval <action> per held action, from held to decided, with moatbox.approval.entry_id, moatbox.approval.decision and moatbox.approval.by,
  • a zero-length span per gateway action, with moatbox.gateway.action and moatbox.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.