Skip to main content
Developers · API v1

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

Mint a key in Settings → API keys with the scopes you need, then call the API. Keys are shown once and stored only as a hash.
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

A key holds exactly the scopes it was minted with. There is no read-everything scope — a 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.
ScopeGrantsEndpoints
costs:readRead aggregated spend for the workspace (grouped, windowed, paginated)./api/v1/costs
recommendations:readRead advisory optimization recommendations and their projected savings./api/v1/recommendations
anomalies:readRead detected spend anomalies with their baseline and severity./api/v1/anomalies
resources:readRead the discovered cloud resource inventory./api/v1/resources
budgets:readRead budget definitions and (optionally) their pacing status./api/v1/budgets
k8s:readReserved for Kubernetes cost reads — no /v1 endpoint consumes this yet.—
llm:readReserved for AI/LLM cost reads — no /v1 endpoint consumes this yet.—
recommendations:writeRecord 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:writeCreate, 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

Every monetary field is an object, never a bare number.
"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*stringThe 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"
  }
}

MCP — connect an agent

The same cost data this API serves, exposed to Claude Code, Claude Desktop, Cursor and any other Model Context Protocol client — with the measurement basis attached to every answer.

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.

Every answer states what it measured

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.

Check it yourself

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.zip

Set 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

Signed, retried, and verifiable — never trust a delivery you have not checked.

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 "", 204

Test 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

Branch on 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": "…" }
}
StatusCode
401missing_credentials
401invalid_credentials
403insufficient_scope
400invalid_parameter
400invalid_cursor
404not_found
405method_not_allowed
409conflict
429rate_limited
500internal_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

What we promise not to break, and how much warning you get when something must change.
  • 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-Version and X-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 Deprecation header appearing and the Sunset date, per RFC 8594. Deprecated endpoints also carry a Link; 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.