Skip to content
Docs/Errors & rate limits

Agents & API

Errors & rate limits

Every error has the same shape. The code is machine-readable, the message is for humans, and the hint usually tells an agent exactly what to do next.

Error envelope

{
  "error": {
    "code": "plan_limit",
    "message": "This plan includes 10 monitors",
    "hint": "The Starter plan includes this. Upgrade at /app/billing, or call POST /api/v1/billing/checkout with {\"plan\":\"starter\"} and hand the checkout URL to a human.",
    "details": { "upgrade": { "plan": "starter", "name": "Starter" } }
  }
}
  • code is stable and safe to branch on. message may change wording.
  • hint is optional, actionable advice (“Send Authorization: Bearer ub_…”, “Use component ids or keys from …”).
  • details is optional, structured context, such as per-field validation errors. For plan_limit it is { "upgrade": { "plan", "name" } }: the cheapest plan that lifts the limit. Pass details.upgrade.plan straight to POST /billing/checkout. A few limits don't name a plan and leave it out; their hint points to /app/billing.

Codes

CodeHTTPMeaning
bad_request400Malformed request, e.g. a JSON body that is not an object, or an unsupported method (returned with HTTP 405).
unauthorized401Missing, malformed, revoked or expired API key. Keys start with ub_.
plan_limit402The workspace plan doesn’t allow this (more monitors, faster interval, custom domain, …). Always includes an upgrade hint. details.upgrade = { plan, name } names the cheapest plan that allows it.
forbidden403Authenticated but not allowed: missing write scope, role, disabled subscription type, unclaimed workspace for email, cross-origin cookie write.
not_found404The resource (or endpoint) doesn’t exist in this workspace.
conflict409Slug, custom domain or component key already taken.
validation_failed422Input failed validation. details lists each problem with its path.
rate_limited429Too many requests for this limiter. Retry later.
internal500A bug on our side. Retry with the same Idempotency-Key.
unavailable503A dependency isn’t available right now (AI, billing).

Validation details

422
{
  "error": {
    "code": "validation_failed",
    "message": "Request validation failed",
    "hint": "See the OpenAPI spec at /api/v1/openapi.json for the exact input shape.",
    "details": [
      { "path": "url", "message": "Invalid URL" },
      { "path": "intervalSec", "message": "Too small: expected number to be >=10" }
    ]
  }
}

Each entry's path is dot-separated (assertions.0.op). Some checks happen after schema validation and come back as validation_failed with just a message and a hint, such as “Cannot infer monitor kind” or “url is required for http monitors”.

Authentication

Send Authorization: Bearer ub_live_…. Keys have read and/or write scopes. A read-only key calling a write endpoint gets 403 (“This API key lacks the "write" scope”). Public endpoints (public status, subscribe, heartbeats, pushes, bootstrap, plans) ignore the header. The dashboard uses a session cookie on the same API. Cookie-authenticated writes must be same-origin.

Rate limits

Authenticated API calls are not rate limited per key. Unauthenticated entry points are:

EndpointLimitKeyed by
POST /agent/bootstrap5 per hourIP address
/hb/<token> and POST /heartbeat/:token120 per minute (shared)Heartbeat token
POST /push/:token120 per minutePush token
POST /public/pages/:slug/subscribers20 per hourIP address
Sign-in emails5 per 15 min per email, 20 per 15 min per IPEmail / IP

Windows are fixed (for example, each clock hour). Exceeding a limit returns 429 rate_limited. Back off until the next window. Public status JSON is cached, so polling it more than every 30 seconds gains nothing.

Idempotency

Send an Idempotency-Key header (any string up to 100 characters) on POST requests that you might retry:

curl -X POST https://upbutler.com/api/v1/incidents \
  -H "Authorization: Bearer $UPBUTLER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: deploy-4812-search-degraded" \
  -d '{"title": "Search degraded during deploy", "message": "Rolling out v2.14."}'
  • The first successful response is stored for 24 hours, scoped to your workspace and the endpoint. Repeats with the same key return that stored response with the header idempotent-replayed: true, without running the operation again.
  • Errors aren't stored, so a failed request can be retried with the same key.
  • It applies to authenticated POSTs. Public endpoints such as bootstrap, heartbeats and pushes ignore it.

Request format

  • Bodies are JSON objects. Send Content-Type: application/json. curl -d without it sends form encoding, which is parsed as form fields.
  • A body that isn't valid JSON is treated as plain text and passed as message. Handy for curl --data-binary "backup ok" to the heartbeat API with Content-Type: text/plain.
  • Query string, body and path parameters are merged, and path parameters win. GET endpoints take their options as query parameters.
  • Unknown endpoints return 404 with a link to the spec. A known path with the wrong method returns 405.

CORS

/api/v1 and /mcp send Access-Control-Allow-Origin: *, so browser-based tools and agents can call them with an API key. Allowed headers: authorization, content-type, idempotency-key, x-workspace (plus mcp-session-id and mcp-protocol-version on /mcp). Preflights are cached for a day. Never ship a write-scoped key to a public web page.

MCP tool errors

Over MCP, a failing tool call returns a normal JSON-RPC result with isError: true, and the same error envelope as text:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "isError": true,
    "content": [{ "type": "text", "text": "{\n  \"error\": {\n    \"code\": \"not_found\",\n    \"message\": \"Monitor not found\"\n  }\n}" }]
  }
}