CloudJaeger REST API
Versioned, API-key authenticated access to your CloudJaeger workspace: read scopes for its costs, recommendations, anomalies, resources and budgets, and 2 narrow write scopes (recommendations:write and budgets:write). Revision 2026-09-25. Described by an OpenAPI 3.1 document you can generate a client from.
Quickstart
curl "https://cloudjaeger.com/api/v1/costs?group_by=service&start=2026-07-01&end=2026-07-31" \
-H "Authorization: Bearer cp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"The key above is a placeholder. Clients that reserve the Authorization header can send the key as X-CloudJaeger-Token instead. Session cookies are not accepted on v1 — it is key-only, so an integration never depends on a browser session.
Scopes & least privilege
costs:read key receives 403 insufficient_scope on every other resource, and a key can only ever reach the workspace that minted it. A write scope is minted separately and is never implied by a read scope.| Scope | Grants | Endpoints |
|---|---|---|
| costs:read | Read aggregated spend for the workspace (grouped, windowed, paginated). | /api/v1/costs |
| recommendations:read | Read advisory optimization recommendations and their projected savings. | /api/v1/recommendations |
| anomalies:read | Read detected spend anomalies with their baseline and severity. | /api/v1/anomalies |
| resources:read | Read the discovered cloud resource inventory. | /api/v1/resources |
| budgets:read | Read budget definitions and (optionally) their pacing status. | /api/v1/budgets |
| k8s:read | Reserved for Kubernetes cost reads — no /v1 endpoint consumes this yet. | — |
| llm:read | Reserved for AI/LLM cost reads — no /v1 endpoint consumes this yet. | — |
| recommendations:write | Record that a recommendation was accepted, applied, rejected or rolled back. Cannot mark one verified — that asserts a saving measured against the bill. | /api/v1/recommendations/{id}/status |
| budgets:write | Create, change and delete this workspace's budget definitions — the config-as-code half of budgets:read. Sets a declared cap; it cannot mark a budget met or breached, because that is measured from the bill. | /api/v1/budgets, /api/v1/budgets/{id} |
An em-dash means the scope is defined but no v1 endpoint consumes it yet.
How to read the numbers
"cost": { "amount": 4213.55, "currency": "USD", "unit": "total", "basis": "measured" }- amount: null means the figure is not available. Render it as an em-dash. It is never 0 as a stand-in for unknown, so do not coerce it.
- basis: "measured" — summed from billed cost data.
- basis: "projected" — a modelled or forward-looking estimate (recommendation savings, anomaly baselines, budget forecasts). Label it as projected wherever you display it; it is not a bill-measured result.
- basis: "declared" — a value the customer set, such as a budget limit.
- unit states the period the amount covers (
total,per-month,per-day), and currency is an ISO-4217 code.
No endpoint in this API changes a cloud resource or executes a change in your cloud account. Its 2 write scopes, recommendations:write and budgets:write, change only CloudJaeger's own records; the scopes table lists exactly what each can do. A recommendation status change is sent as a recommendation.status_changed event to any webhook endpoints the workspace has registered. A write scope is minted separately and is never implied by a read scope. Recommendations are advisory: they carry advisoryOnly: true and a human applies them.
Endpoints
Generated from the same OpenAPI document served at /api/v1/openapi.json — this page and the spec cannot disagree.
Records that a recommendation was accepted, applied, rejected or rolled back. An id this workspace has no recommendation for returns 404 and nothing is written — no status row, no audit entry, no webhook event. Illegal transitions return 409 rather than being written, so a saving marked verified cannot be walked backwards and re-counted. `verified` is NOT settable here — it asserts a saving measured against the bill, which only savings reconciliation may claim.
Path parameters
| id* | string | The recommendation key, exactly as returned by GET /api/v1/recommendations. Scoped to the organisation the API key belongs to — knowing an id does not grant access to it. |
Example response
{
"data": {
"recommendationId": "ebs-gp2-to-gp3:arn:aws:ec2:eu-central-1:111111111111:volume/vol-0abc",
"status": "applied",
"previousStatus": "accepted",
"takenAt": "2026-08-08T10:00:00.000Z"
},
"meta": {
"apiVersion": "v1",
"revision": "2026-09-25",
"requestId": "3f6a2c18-9b41-4f0e-8d55-2c1a7e4b90d3",
"generatedAt": "2026-08-01T09:15:00.000Z"
}
}Pagination
List responses carry meta.pagination. When hasMore is true, pass nextCursor back as ?cursor= to fetch the next page; stop when nextCursor is null.
A cursor is tied to the filters it was issued under. Changing a filter (a window, a grouping) and reusing the old cursor returns 400 invalid_cursor rather than quietly returning rows from a different query — restart pagination without a cursor instead.
Rate limits are per key: 60 requests of burst and 120 per minute sustained. A 429 carries Retry-After plus the standard X-RateLimit-* headers.
MCP — connect an agent
POST /api/mcp speaks JSON-RPC 2.0 over HTTP. It is the request/response half of Streamable HTTP and nothing more: every POST is answered on the POST that asked for it, there is no server-initiated stream, and no session id is issued. A GET with Accept: text/event-stream returns 405 rather than a 200 the client cannot parse.
Two ways to authenticate, both supported. OAuth 2.1 — authorization code with PKCE (S256), and RFC 7591 dynamic client registration, so a client connects without anyone handling a credential. Or an API key with the mcp:read scope, which is what a CI job wants, where there is no browser to complete a flow in. Neither is a downgrade of the other.
Discovery starts at /.well-known/oauth-protected-resource (RFC 9728). A client that receives a 401 from the MCP endpoint also reads that URL out of the WWW-Authenticate header, so no configuration is needed beyond the server URL itself.
Tool results carry a structured provenance envelope alongside the readable text: which window was actually read and whether it was moved back to reach the newest billed day, how many connections were counted and how many were excluded, whether the figure is measured, estimated or unavailable, and a derived confidence. An agent can branch on those fields rather than parsing English.
A figure that could not be measured is returned as state: "unavailable" with a reason code and a remedy — never as a zero. That refusal is a documented part of the contract, not an error path.
Key-based answers agree to the cent with the web app: list the tools with a read key, call one, and compare its figure with the same view in the app (this needs an account).
curl -s -X POST https://cloudjaeger.com/api/mcp -H "Authorization: Bearer cp_…" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The agent plugin we publish is pinned by checksum in a public manifest: fetch /api/mcp/marketplace.json and the archive, and compare.
curl -s -o plugin.zip https://cloudjaeger.com/api/mcp/plugin.zip && shasum -a 256 plugin.zipSet up a client (sign-in required) for the copy-paste configuration, the live tool catalog, and the draft-proposal inbox. The catalog there is read from the registry the endpoint actually serves, so it cannot drift from what your agent will see.
Draft tools (scope mcp:draft) save a pending proposal and nothing else — a signed-in admin must confirm it in the app before anything is created. Nothing on this surface changes a cloud account, and nothing is executed.
Webhooks
An owner or admin registers an endpoint with POST /api/settings/webhooks, and disables, re-points, rotates or removes it with /api/settings/webhooks/{id} — or from Settings → Webhooks (sign-in required) (/settings/webhooks). The signing secret is shown once, at creation, and cannot be retrieved afterwards — a secret we could hand back on request is one anybody with a session could read, and proving the delivery came from us is its only job. Lost it? Rotate; do not go looking for it. Each event is sent as it happens; one that does not land waits for a retry sweep, and until a sweep runs it is queued, not yet delivered — the endpoint list says how many are waiting.
Every delivery carries X-CloudJaeger-Signature, X-CloudJaeger-Event-Id and X-CloudJaeger-Event-Type. Your endpoint URL is public, so anyone who learns it can POST to it: the signature is the only thing that distinguishes us from a forgery.
X-CloudJaeger-Signature: t=1786291200,v1=5c3f...9ab2
The signature is HMAC-SHA256(secret, timestamp + "." + rawBody), hex-encoded — the timestamp is signed with the body, not sent beside it. Three details decide whether your verification actually works:
- Verify the RAW body, exactly as received. Re-serialising parsed JSON (
JSON.stringify(req.body)) changes key order and unicode escaping, and valid signatures will fail. - Compare in constant time. A plain
===leaks timing information about how much of the digest matched. - Reject old timestamps — 300 seconds is our tolerance. The timestamp is inside the MAC, so it cannot be rewritten; checking it is what stops a captured delivery being replayed forever.
// Node — verify before trusting anything in the body.
import { createHmac, timingSafeEqual } from "crypto";
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}# Python — same three rules. Flask shown; any framework works, as long as you
# can reach the RAW body. request.get_json() is already too late.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(
p.strip().split("=", 1) for p in header.split(",") if "=" in p
)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
# Both directions: a future timestamp replays for as long as it stays
# "not yet expired".
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
# compare_digest, never ==
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/cloudjaeger-webhook")
def receive():
if not verify(request.get_data(), request.headers.get("X-CloudJaeger-Signature", ""), SECRET):
return "", 400
event = request.get_json() # safe only AFTER verifying
return "", 204Test your receiver before an event depends on it. POST /api/settings/webhooks/{id}/test — or Send test event on Settings → Webhooks — sends one signed event of type webhook.test to that endpoint now, whatever events it subscribes to. It arrives exactly as a real delivery does, signed the same way, with X-CloudJaeger-Event-Type: webhook.test and data.test: true in the body, so your receiver can tell it from a real event. An owner or admin can send five a minute, to an enabled endpoint, and the response says what your receiver answered. A test event is attempted once and never retried, and one that fails does not count towards switching the endpoint off.
Retries. A 2xx means delivered. A 5xx, a 429 or a timeout is retried by a retry sweep with exponential backoff for up to 8 attempts; until a sweep runs, a failed attempt is queued, not yet delivered. Any other 4xx stops immediately — a 404 will not become a 200, and because deliveries are ordered per endpoint, retrying one that cannot land would delay everything behind it. Return 2xx as soon as you have stored the event; do your work afterwards.
Duplicates are possible and are your friend. If your endpoint accepts a delivery but the response never reaches us, we retry. Deduplicate on X-CloudJaeger-Event-Id, which is stable across every attempt of the same event — including when the API call that produced it is itself retried.
We will switch a dead endpoint off. After 20 consecutive failed deliveries the endpoint is disabled and the reason is returned as disabledReason by GET /api/settings/webhooks, and shown on Settings → Webhooks beside the endpoint. Events are not queued while an endpoint is disabled — fix the receiver, then re-enable it.
Endpoints must be https:// and resolve to a publicly routable address. A host that resolves into private or link-local space is refused, at registration and again before every delivery.
Errors
error.code, not on the message text or the status alone.{
"error": {
"code": "insufficient_scope",
"message": "This API key is missing the `recommendations:read` scope.",
"docs": "/developers#errors",
"details": { "requiredScope": "recommendations:read", "grantedScopes": ["costs:read"] }
},
"meta": { "apiVersion": "v1", "revision": "2026-09-25", "requestId": "…", "generatedAt": "…" }
}| Status | Code |
|---|---|
| 401 | missing_credentials |
| 401 | invalid_credentials |
| 403 | insufficient_scope |
| 400 | invalid_parameter |
| 400 | invalid_cursor |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | conflict |
| 429 | rate_limited |
| 500 | internal_error |
401 vs 403 matters. 401 means we could not identify the key (absent, unknown or revoked). 403 means the key is valid but was not minted with the scope the endpoint requires — re-mint it with that scope rather than retrying.
Versioning & deprecation policy
- Major version in the path. Breaking changes ship as
/api/v2. v1 keeps answering until its announced sunset date. - Additive changes are not breaking. New optional fields, new endpoints and new enum members can appear within v1. Your client must ignore unknown fields.
- Dated revisions. Every response carries
X-CloudJaeger-API-VersionandX-CloudJaeger-API-Revision(currently 2026-09-25). Log them; quote them in a support request. - Deprecation notice. At least 90 days before an endpoint is marked deprecated, and at least 180 days between the
Deprecationheader appearing and theSunsetdate, per RFC 8594. Deprecated endpoints also carry aLink; rel="deprecation"header pointing here. - Scopes are additive. Existing keys never silently gain access; a new capability needs a new scope on a newly minted key.
Status
CloudJaeger runs an invite-gated design partner programme. v1 reads costs, recommendations, anomalies, resources and budgets, and writes only through recommendations:write and budgets:write; the older unversioned /api/* routes remain internal, session-authenticated, and are not part of this contract.
k8s:read and llm:read are defined but not yet consumed by any v1 endpoint — the Kubernetes and AI cost surfaces are still session-only. They are listed above with an em-dash rather than implying coverage.
OpenAPI document version: 2026-09-25 · 11 documented operations.
