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" } }
}
}codeis stable and safe to branch on.messagemay change wording.hintis optional, actionable advice (“Send Authorization: Bearer ub_…”, “Use component ids or keys from …”).detailsis optional, structured context, such as per-field validation errors. Forplan_limitit is{ "upgrade": { "plan", "name" } }: the cheapest plan that lifts the limit. Passdetails.upgrade.planstraight toPOST /billing/checkout. A few limits don't name a plan and leave it out; their hint points to/app/billing.
Codes
| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | Malformed request, e.g. a JSON body that is not an object, or an unsupported method (returned with HTTP 405). |
unauthorized | 401 | Missing, malformed, revoked or expired API key. Keys start with ub_. |
plan_limit | 402 | The 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. |
forbidden | 403 | Authenticated but not allowed: missing write scope, role, disabled subscription type, unclaimed workspace for email, cross-origin cookie write. |
not_found | 404 | The resource (or endpoint) doesn’t exist in this workspace. |
conflict | 409 | Slug, custom domain or component key already taken. |
validation_failed | 422 | Input failed validation. details lists each problem with its path. |
rate_limited | 429 | Too many requests for this limiter. Retry later. |
internal | 500 | A bug on our side. Retry with the same Idempotency-Key. |
unavailable | 503 | A dependency isn’t available right now (AI, billing). |
Validation details
{
"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:
| Endpoint | Limit | Keyed by |
|---|---|---|
POST /agent/bootstrap | 5 per hour | IP address |
/hb/<token> and POST /heartbeat/:token | 120 per minute (shared) | Heartbeat token |
POST /push/:token | 120 per minute | Push token |
POST /public/pages/:slug/subscribers | 20 per hour | IP address |
| Sign-in emails | 5 per 15 min per email, 20 per 15 min per IP | Email / 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 -dwithout 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 forcurl --data-binary "backup ok"to the heartbeat API withContent-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
404with a link to the spec. A known path with the wrong method returns405.
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}" }]
}
}