# LevelFour > LevelFour is a cloud cost optimization platform. It finds savings in your > cloud bill, and applies the ones you accept. These docs cover setting up and > using LevelFour, the REST API, the official SDKs, the `l4` CLI, and the remote > MCP server. ## How to read these docs Every page below is available as Markdown, at the URL listed against it. Append `.md` to any documentation URL, or send `Accept: text/markdown` to the page itself. The home page is served at `/index.md`, since `/.md` is not a path. Other entry points: - MCP server: `https://mcp.levelfour.ai/mcp`, read and write tools over your spend and savings - REST API: `https://api.levelfour.ai`, authenticated with `Authorization: Bearer l4_live_` - OpenAPI: `https://api.levelfour.ai/openapi.json` - SDKs: `pip install levelfour`, `npm install levelfour`, `go get github.com/LevelFourAI/levelfour-go` This file is every page above, in full. For one page, fetch its `.md` URL. --- # LevelFour Documentation Source: https://docs.levelfour.ai/index.md LevelFour is a cloud cost optimization platform. It reads your bills from AWS, Google Cloud and the AI, data and developer tools your teams use, shows where the money goes, and finds what you can stop paying for. When you accept a saving, LevelFour ships it the way you choose: a pull request against your infrastructure code, or a change it makes in your account with access you grant for that change alone. - [Quickstart](/quickstart): Sign in, connect your first provider, and see your costs and first savings. - [Savings Recommendations](/savings-recommendations): Review what LevelFour found, accept what fits, and choose how each change ships. - [Developers](/api-quickstart): Read the same data from the REST API, an SDK, the l4 CLI, or an assistant over MCP. ## Set up your organization - [Connect a provider](/connectors): AWS, Google Cloud, and the AI, data and developer tools your teams pay for. - [Connect GitHub or GitLab](/integrations): Cost comments on every pull request, and savings delivered as pull requests. - [Give your team the right roles](/account/members-and-roles): Viewers read everything, members act on savings, admins approve rollouts. - [Turn on single sign-on](/account/sso): Sign in through your identity provider with SAML. - [Let LevelFour apply savings on AWS](/connectors/aws/automated-savings): Grant scoped, time-limited access so an accepted saving applies itself. - [Ask the FinOps Agent](/finops-agent): Ask about your own spend in plain language, from the dashboard. ## Build with LevelFour - [API Reference](/api-reference): Every REST endpoint, by category, with request and response shapes. - [SDKs](/sdks): Official clients for Python, TypeScript and Go. - [l4 CLI](/cli): Your spend and savings in a terminal or a CI job, plus Terraform and CloudFormation cost estimates. - [MCP server](/mcp): Claude, Cursor, VS Code and any MCP client, reading and acting on your savings. - [FinOps glossary](https://levelfour.ai/glossary): Plain-language definitions of the cloud cost and FinOps terms teams ask about most. - [Research and reports](https://levelfour.ai/research): Original research on cloud and Kubernetes cost, including The State of Cloud Waste 2026. ## For agents and assistants Every page is also served as Markdown: add .md to its URL, and read this page at [/index.md](/index.md). [llms.txt](/llms.txt) indexes every page by section, and [llms-full.txt](/llms-full.txt) holds all of them in one file. ## Help Write to [contact@levelfour.ai](mailto:contact@levelfour.ai), or talk to your LevelFour contact. For questions about your own spend, ask the FinOps Agent in the dashboard. --- # Quickstart Source: https://docs.levelfour.ai/quickstart.md > **Tip: Start with one provider** > > Connect a single cloud provider first. LevelFour has something to show you within a day of the first sync, and you can add the rest whenever you want. ### Sign in Open [dashboard.levelfour.ai](https://dashboard.levelfour.ai) and sign in with the email address your organization invited. If your company signs in through an identity provider, [Enterprise SSO](/account/sso) covers how that works. > **Note: No invitation yet?** > > LevelFour sets up each organization before its first sign-in. Write to [contact@levelfour.ai](mailto:contact@levelfour.ai), or ask your LevelFour contact. ### Connect your first provider Open **Connect Providers** in the dashboard sidebar and pick a provider. Each one connects with a read-only credential you create in that provider's own console. Pick one to start: - [Amazon Web Services](/connectors/aws) - [Google Cloud](/connectors/gcp) - [OpenAI](/connectors/openai) - [Anthropic](/connectors/anthropic) - [Snowflake](/connectors/snowflake) - [Datadog](/connectors/datadog) [All connectors](/connectors) lists every provider, what LevelFour reads from each, and the ones that are not self-service yet. ### See your costs and first savings Costs appear on **Boards** and under **Costs and Usage** after the first sync. Savings appear under **Savings** as LevelFour finds them, each with what it saves a month and what would change. > **Note** > > AWS costs arrive within a day of the first sync. Google Cloud billing data takes about two days, because Google delivers the first export rows 24 to 48 hours after you turn the export on. [Boards](/boards), [Costs and Usage](/cost-and-usage) and [Savings Recommendations](/savings-recommendations) walk through each page. ### Add your team and connect your repositories Your LevelFour contact adds teammates to your organization. Once they are in, an admin sets each person's role under **Settings**, then **Members**. [Members and roles](/account/members-and-roles) covers what each role can do. Connect GitHub or GitLab from **Settings**, then **Integrations**, so LevelFour can comment the cost of a change on its pull request and deliver accepted savings as pull requests. See [Integrations](/integrations). ## Next steps * [Automated Savings](/connectors/aws/automated-savings) lets an accepted saving on AWS apply itself, with access scoped to that change. * [Implementation methods](/savings-recommendations/implementation-methods) compares delivering a saving as a pull request, as an automated change, or by hand. * [FinOps Agent](/finops-agent) answers questions about your own spend in plain language. * Press ⌘K (Ctrl+K on Windows and Linux) anywhere in the dashboard to jump to a page. * [API Quickstart](/api-quickstart) reads the same data from code. --- # All connectors Source: https://docs.levelfour.ai/connectors.md LevelFour reads cost and usage data from the providers your teams already use. You connect each one yourself from **Connect Providers** in the dashboard, with a least-privilege credential you create in that provider's own console. ## Cloud - [Amazon Web Services](/connectors/aws): Costs for one account or a whole AWS organization, through read-only IAM roles. - [Google Cloud](/connectors/gcp): Projects and billing export, connected with one command in your own Cloud Shell. - [DigitalOcean](/connectors/digitalocean): Daily spend per product and resource, invoices, and the resources in a team. - [Cloudflare](/connectors/cloudflare): Usage overage per product and zone. - [Azure](/connectors/azure) (Coming soon): Not self-service yet. Your LevelFour contact connects an Azure tenant today. - [Kubernetes](/connectors/kubernetes) (Coming soon): Not self-service yet. Your LevelFour contact connects a cluster today. ## AI - [OpenAI](/connectors/openai): The API bill per project, model and key, ChatGPT Enterprise per person, and Codex and app usage. - [Anthropic](/connectors/anthropic): Claude on every plan: the API and Enterprise bill, and Claude Code usage and activity. - [DeepSeek](/connectors/deepseek): The monthly usage export as your bill, and what Claude Code, Codex and your apps run on DeepSeek. - [Cursor](/connectors/cursor): Usage-based model spend per user and model. - [Baseten](/connectors/baseten): Dedicated inference, training, and Model APIs spend. - [Modal](/connectors/modal): Compute cost per app and environment. - [Hugging Face](/connectors/huggingface): Inference, Spaces, Endpoints, and Jobs spend. - [Anyscale](/connectors/anyscale): Instance usage per cloud, project, and instance type, priced from your rate. ## Developer tools - [GitHub billing](/connectors/github-billing): Actions, Copilot, Codespaces, Packages, LFS, and shared storage spend. - [GitLab billing](/connectors/gitlab-billing): Seats, GitLab Credits, and compute minutes, priced from your rates. - [Slack billing](/connectors/slack-billing): Your Slack contract amount. - [CircleCI](/connectors/circleci): Credits per project, workflow, and job, priced from your rate. - [Temporal Cloud](/connectors/temporal): Actions and storage spend per namespace. - [Twilio](/connectors/twilio): Daily usage by category. ## Data and observability - [Datadog](/connectors/datadog): Estimated and historical cost per product. - [Snowflake](/connectors/snowflake): Organization usage in currency. - [Databricks](/connectors/databricks): Platform charges from your system tables. - [ClickHouse Cloud](/connectors/clickhouse): Compute, storage, backup, and transfer spend. - [MongoDB Atlas](/connectors/mongodb): Invoice line items per cluster and SKU. - [Redis Cloud](/connectors/redis): FOCUS cost report per subscription and database. - [Grafana Cloud](/connectors/grafana): Billed usage per product and stack. - [New Relic](/connectors/newrelic): Ingested data and user seats, priced from your rates. - [Elastic Cloud](/connectors/elastic): Elastic Consumption Units per deployment and product. ## Custom For a provider with no connector of its own. - [Upload a FOCUS CSV](/connectors/custom-focus): Whatever your file contains, in the FOCUS format. - [Add a recurring cost](/connectors/custom-recurring): A flat monthly amount you enter. > **Success** > > Each connector shows as **Connected** on its own panel under **Connect Providers** in the dashboard. ## Next * [Integrations](/integrations) connects GitHub, GitLab, Slack and Jira, where LevelFour writes back * [Quickstart](/quickstart) is the order to set everything up in --- # Connect AWS Source: https://docs.levelfour.ai/connectors/aws.md LevelFour reads your AWS costs through read-only IAM roles in your accounts. Both paths below create the same roles under predictable names, so you never copy an ARN back to LevelFour. ## Pick a path | [Single account](/connectors/aws/single-account), if any of these is true | [Multi-account](/connectors/aws/multi-account), if all of these are true | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | You have one AWS account, or a few you onboard one at a time | You use AWS Organizations with **all features** mode enabled | | You do not use AWS Organizations, or only for consolidated billing | You want to onboard many member accounts at once, including future ones | | You prefer to grant access to each account explicitly | You can deploy CloudFormation from the management account, or a delegated StackSets admin account | ## Then, when you want them * [Detailed cost data](/connectors/aws/detailed-cost-data) adds resource-level detail and tag-based allocation from a Cost and Usage Report. * [Automated Savings](/connectors/aws/automated-savings) lets LevelFour apply a saving you accept, with access scoped to that change. --- # Single account Source: https://docs.levelfour.ai/connectors/aws/single-account.md LevelFour reads your AWS costs through IAM roles it creates in your account. You can connect one account from the dashboard in a click, or with a CloudFormation Launch Stack link where you enter the handshake ID LevelFour gave you. If your accounts sit under AWS Organizations, the [multi-account path](/connectors/aws/multi-account) onboards every one of them in a single deployment. ## Self-service path (recommended) The whole flow runs from the LevelFour dashboard without an onboarding contact in the loop. Go to **Connect Providers > AWS** in the dashboard (`https://dashboard.levelfour.ai/connect-accounts/aws`) and click **Connect via AWS Console**. The dashboard opens AWS Console with the v2 CloudFormation template pre-loaded. Click **Create stack**, and the deployed stack pings LevelFour automatically. > **Success** > > The dashboard flips to **Connected** within ~30 seconds and starts ingesting cost data for that account. Nothing to send back to LevelFour, and no ARNs to copy. ## Prerequisites * An AWS account with permission to create IAM roles and CloudFormation stacks (typically an admin role) * The **LevelFour Handshake ID** provided by your LevelFour contact ## Deploy with a Launch Stack link Use the steps below when you cannot reach the dashboard, for example when the person who holds AWS access is not the LevelFour admin. ### Open the Launch Stack link Sign in to the AWS account you want to onboard, then click the Launch Stack link below. It opens the CloudFormation console with the LevelFour onboarding template pre-loaded. ```text https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-cloudformation-template.json&stackName=LevelFour ``` Region does not matter for IAM resources. Leave it on `us-east-1`. > **Warning** > > CloudFormation deploys into whichever account the console is signed in to. Check the top-right account selector before you create the stack. A stack in the wrong account creates a role there that trusts LevelFour, and undoing it means deleting that stack. ### Enter the handshake ID CloudFormation shows the template's parameters: * **LevelFourHandshakeID**: paste the value LevelFour gave you. Treat it like a secret, and do not share or change it. * **IntegrationID** and **PingbackTopicArn**: leave both blank. Only the dashboard self-service flow uses them, to auto-confirm the connection. Leave the stack name as `LevelFour`, or change it if your team uses a naming convention. > **Warning** > > A handshake ID that does not match still deploys cleanly. The stack reaches `CREATE_COMPLETE` and the connection never works. Paste the value, do not retype it. ### Acknowledge the IAM capability At the bottom of the page, tick: > I acknowledge that AWS CloudFormation might create IAM resources with custom names. AWS asks for this because the template creates roles with specific names (`LevelFourCrossAccountRole`, plus an empty `LevelFourSavingsExec` role for Automated Savings) rather than letting AWS auto-generate them. Predictable names let LevelFour find the roles without you copying ARNs back to LevelFour. Click **Create stack**. ### Wait for `CREATE_COMPLETE` CloudFormation provisions the role. It typically takes 30 to 60 seconds. Watch the **Events** tab for progress. > **Success** > > When the stack status reaches `CREATE_COMPLETE`, the role exists in your account. Whether LevelFour can use it is confirmed in the next step, not here. ### Confirm the role and notify LevelFour Open the stack's **Outputs** tab: * **RoleArn**: `arn:aws:iam:::role/LevelFourCrossAccountRole` * **SavingsExecRoleArn**: `arn:aws:iam:::role/LevelFourSavingsExec` (the Automated Savings execution role) * **HandshakeID**: the same value you entered, echoed back for verification Reply to your LevelFour contact with the AWS account ID. LevelFour validates that the role works (it attempts an `sts:AssumeRole` using your handshake ID) and starts ingesting data for that account. ## Onboarding more accounts Repeat the deploy in each additional AWS account, using the same handshake ID. Each account gets its own role with the same predictable name. Past three or four accounts, switch to the [multi-account path](/connectors/aws/multi-account). ## What the stack creates The stack creates two roles. Both trust LevelFour's AWS account (`730335248431`) and only accept AssumeRole requests that include your specific `LevelFourHandshakeID` as the external ID. `LevelFourCrossAccountRole` is the read-only role LevelFour uses to analyze your costs. Its permissions: * AWS managed `ReadOnlyAccess` (read-only access to all resources) * A focused inline policy covering: Cost Explorer, Compute Optimizer, Cost Optimization Hub, Savings Plans, Pricing, AWS Support, Trusted Advisor, S3 Storage Lens, BCM Data Exports, Container Insights, and the IAM service-linked role creation needed to enroll in those services `LevelFourSavingsExec` is created empty, with no attached permissions. It exists so that, when you approve a specific [Automated Savings](/connectors/aws/automated-savings) action, a scoped policy covering only that action and its resources is attached for a short, time-limited window and then expires on its own. Nothing in your account can be changed until you explicitly approve such an action. You can review the full template before deploying: ```text https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-cloudformation-template.json ``` ## Removing access To revoke LevelFour's access to an account, delete the CloudFormation stack: ```bash aws cloudformation delete-stack --stack-name LevelFour ``` This removes both roles (`LevelFourCrossAccountRole` and `LevelFourSavingsExec`). LevelFour stops being able to read data from that account immediately. ## Troubleshooting | Symptom | Cause | Fix | | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `CREATE_FAILED`, "Role with name LevelFourCrossAccountRole already exists" | The LevelFour role is already deployed in this account | Skip it, the account is already onboarded. To redeploy, delete the existing role first | | `CREATE_FAILED` with an SCP-related error | Service Control Policies in your AWS Organization block IAM role creation in this account | Ask your AWS administrator to permit `iam:CreateRole`, `iam:AttachRolePolicy` and `iam:PutRolePolicy` for the role name `LevelFourCrossAccountRole` | | The stack succeeded but LevelFour says the role is not accessible | The handshake ID you entered does not match what LevelFour provided | Update the stack and re-enter the correct value | ## Next * [Detailed cost data](/connectors/aws/detailed-cost-data) turns on the Cost and Usage Report, which is what gives you resource-level and tag-level breakdowns * [Automated Savings](/connectors/aws/automated-savings) covers the `LevelFourSavingsExec` role, including accounts onboarded before it existed --- # Multi-account Source: https://docs.levelfour.ai/connectors/aws/multi-account.md If your company uses AWS Organizations, you can onboard every member account in one deployment. CloudFormation StackSets creates the LevelFour cross-account role in every account under your deployment target: your organization's root, which covers every account, or a specific OU, which covers a subset. Accounts that join later are covered too. If you only have a handful of accounts or you do not use AWS Organizations, see the [single-account path](/connectors/aws/single-account) instead. ## Self-service path (recommended) The whole flow runs from the LevelFour dashboard without an onboarding contact in the loop. Go to **Connect Providers > AWS** in the dashboard (`https://dashboard.levelfour.ai/connect-accounts/aws`), switch the tab to **AWS Organization**, paste your AWS Organizations **root ID** (`r-...`) or an **OU ID** (`ou-...-...`) into the input, then click **Connect via AWS Console**. The dashboard opens AWS Console with the v2 StackSet wrapper template fully pre-filled: handshake, per-click integration ID, pingback ARN and the target ID are all populated. One click on **Create stack** kicks off the deploy. Once the wrapper stack finishes in your management account, LevelFour verifies the role and registers every active member account. > **Success** > > The dashboard flips to **Connected** with the full discovered list within ~30 seconds. That is the whole confirmation, and there is nothing to send us. You do not need to run the preflight script for this path. The dashboard collects the one piece of information the AWS Console cannot pre-fill itself, your Root or OU ID. The `Find this →` link in the dashboard input opens the AWS Organizations console where the Root ID is shown at the top of the accounts tree. ## How it works You deploy a small **wrapper template** in your management account. The wrapper contains a `AWS::CloudFormation::StackSet` resource that references the standard LevelFour onboarding template and fans it out to every account under the deployment target you specify (root or OU). * One deploy, one approval, the same as the single-account Launch Stack flow * Auto-deployment is enabled by default * LevelFour discovers all your accounts via AWS Organizations after deployment and locates the role in each at `arn:aws:iam:::role/LevelFourCrossAccountRole`, so you do not copy any role ARNs back to LevelFour * Each account gets the read-only `LevelFourCrossAccountRole` plus an empty `LevelFourSavingsExec` role for [Automated Savings](/connectors/aws/automated-savings). See [what the stack creates](/connectors/aws/single-account#what-the-stack-creates) ## Prerequisites These need to be true before you deploy. ### AWS Organizations is in "all features" mode Open the AWS Organizations console > **Settings**. The "Feature set" must show **All features**. If it shows "Consolidated billing," click **Begin process to enable all features** and have every member account accept the invitation before continuing. ### Trusted access for CloudFormation StackSets is enabled This is a **two-sided** toggle. Service-managed StackSets need permission from both Organizations and CloudFormation, and these are activated separately. **Organizations side.** Open the AWS Organizations console > **Services**. Find **CloudFormation StackSets** and confirm **Trusted access** is **Enabled**. If it is disabled: ```bash aws organizations enable-aws-service-access \ --service-principal=member.org.stacksets.cloudformation.amazonaws.com ``` **CloudFormation side.** Activate Organizations access on the CloudFormation StackSets service itself. That is a separate toggle, it is not enabled by default, and it is per-region: ```bash aws cloudformation activate-organizations-access --region us-east-1 ``` You can confirm it is on with: ```bash aws cloudformation describe-organizations-access --region us-east-1 # Expected: {"Status": "ENABLED"} ``` > **Warning** > > Skip the CloudFormation-side activation and the StackSet deploys with the misleading error `You must enable organizations access to operate a service managed stack set`, which points at the toggle you already enabled. The preflight script catches this for you. ### You are signed in to the management account The Launch Stack link must be opened in the AWS Organizations management account (or in a delegated CloudFormation StackSets admin account). The top-right account selector in the AWS console must match. ### No SCP blocks IAM role creation in the target OU If your organization uses Service Control Policies, none of the SCPs attached to the OU you will target may deny `iam:CreateRole`, `iam:AttachRolePolicy`, or `iam:PutRolePolicy` for roles named `LevelFourCrossAccountRole`. If you are not sure, [run the preflight check](#run-the-preflight-check). It cannot check SCPs directly, but a dry-run deploy against one account in the OU will surface any block. ## Manual path These steps are the fallback for when you prefer to deploy via the AWS CLI or IaC, or cannot reach the dashboard from the management account. ### Run the preflight check In AWS CloudShell signed in to your management account, run: ```bash curl -fsSL https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v1/levelfour-preflight.sh | bash ``` One line per check: ```text Running LevelFour onboarding preflight... [OK] Organizations in all-features mode [OK] Organizations trusted access for StackSets enabled [OK] CloudFormation Organizations access activated [OK] Running from management account (123456789012) Available deployment targets (pick one as TargetOUId in the next step): Root: r-abcd (Root) -> onboards EVERY account in the organization No OUs configured. Use the root ID above to onboard every account. Preflight complete. ``` To read the script first: ```bash curl -fsSL https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v1/levelfour-preflight.sh -o preflight.sh less preflight.sh bash preflight.sh ``` Pick a target ID from the output: * The **root ID** (e.g. `r-abcd`) onboards every account in your organization in one shot. Use this for the simplest rollout. * An **OU ID** (e.g. `ou-abcd-12345678`) scopes the rollout to a subset (e.g. start with `Non-Production`, add `Production` later by re-running with a different OU). ### Deploy the StackSet > **Warning** > > AWS Organizations excludes the **management account** from SERVICE_MANAGED StackSet targeting, so the StackSet never reaches it. The Launch Stack wrapper covers it with an inline role. On the other two paths you deploy the role there yourself, and skipping that leaves LevelFour unable to call `organizations:ListAccounts` in your management account. **Launch Stack** Recommended. This is the equivalent of the single-account Launch Stack link, but it deploys a StackSet, and its wrapper stack creates the role in your management account as well. Sign in to your management account, then open: ```text https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-stackset-wrapper.template.json&stackName=LevelFour-Onboarding¶m_LevelFourHandshakeID=¶m_TargetOUId= ``` Enter the two parameters: * **LevelFourHandshakeID**: the value LevelFour provided * **TargetOUId**: the root ID or OU ID from the preflight output At the bottom of the page, tick: > I acknowledge that AWS CloudFormation might create IAM resources with custom names. Click **Create stack**. CloudFormation provisions the StackSet, which deploys the role to every account under the target. Status reaches `CREATE_COMPLETE` once all per-account stacks succeed, typically 2 to 5 minutes for a small target and longer for larger ones. **StackSets console** Use this if you want to see and confirm every StackSet option before clicking Create. Creating the StackSet from the console rather than through the Launch Stack wrapper template deploys the role only to accounts under your target OU or root, and this path has no inline role for the management account. Item 9 covers it. The **Launch Stack** tab does both in one deploy. 1. Console > **CloudFormation** > **StackSets** > **Create StackSet** 2. **Permissions** > **Permission model:** Service-managed permissions 3. **Prerequisite - Prepare template** > **Prepare template:** Template is ready 4. **Specify template** > **Template source:** Amazon S3 URL > paste: ```text https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-cloudformation-template.json ``` Click **Next**. 5. **Specify StackSet details:** * **StackSet name:** `LevelFour-Onboarding` * **Parameters > `LevelFourHandshakeID`:** your handshake value Click **Next**. 6. **Configure StackSet options:** * **Tags:** leave empty (optional) * **Execution configuration > Managed execution:** leave **Inactive** (the default, and fine for a one-shot onboarding deploy; "Active" only matters if you will run concurrent StackSet operations) * **Capabilities:** tick **I acknowledge that AWS CloudFormation might create IAM resources with custom names** Click **Next**. 7. **Set deployment options:** * **Add stacks to stack set:** Deploy new stacks (default) * **Deployment targets:** * If you are onboarding the whole organization, pick **Deploy to organization**. No OU ID input needed, because AWS targets every member account under the root. * If you are scoping to a specific OU, pick **Deploy to organizational units (OUs)** and paste the OU ID from the preflight. * **Auto-deployment options:** * **Automatic deployment:** Activated * **Account removal behavior:** Delete stacks * **Stack set dependencies:** leave empty * **Specify Regions:** from the dropdown, add `US East (N. Virginia) us-east-1` only. IAM is global, so one region is enough. * **Deployment options:** leave the defaults (Maximum concurrent accounts = 1, Failure tolerance = 0, Region concurrency = Sequential, Concurrency mode = Strict failure tolerance). For orgs with 100+ accounts where the rollout takes long, you can raise concurrency here. The defaults are safest. Click **Next**. 8. **Review**, then click **Submit**. 9. **Deploy the role to the management account.** Go through the [single-account flow](/connectors/aws/single-account) once in the management account with the same handshake ID. Skip this only if you do not need LevelFour to call `organizations:ListAccounts` from your management account (rare). **AWS CLI** For IaC-first teams. The first two commands create the StackSet and its stack instances, which reach member accounts only. The third deploys the same per-account template into the management account with the same handshake, which is what the [single-account flow](/connectors/aws/single-account) does from the console. The **Launch Stack** tab handles both in one deploy. ```bash aws cloudformation create-stack-set \ --stack-set-name LevelFour-Onboarding \ --template-url https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-cloudformation-template.json \ --permission-model SERVICE_MANAGED \ --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \ --capabilities CAPABILITY_NAMED_IAM \ --parameters ParameterKey=LevelFourHandshakeID,ParameterValue= aws cloudformation create-stack-instances \ --stack-set-name LevelFour-Onboarding \ --deployment-targets OrganizationalUnitIds= \ --regions us-east-1 aws cloudformation create-stack \ --stack-name LevelFour-ManagementAccount \ --template-url https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-cloudformation-template.json \ --capabilities CAPABILITY_NAMED_IAM \ --parameters ParameterKey=LevelFourHandshakeID,ParameterValue= \ --region us-east-1 ``` ### Tell LevelFour you are done Send your LevelFour contact your **management account ID**. LevelFour already has your handshake. It uses that to assume the role in your account, verify the role works in every active account, and populate your organization in LevelFour. You do not need to copy ARNs back to LevelFour. **Handoff script** Recommended. In AWS CloudShell signed in to your management account: ```bash curl -fsSL https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v1/levelfour-handoff.sh | bash ``` It auto-detects your wrapper stack and prints the stack outputs, then one combined table of **every active organization account** with its LevelFour role status. Copy the whole picture into your reply. ```text Wrapper stack outputs: ----------------------------------------------------------- | Output | Value | |-----------------------|---------------------------------| | ManagementAccountId | 123456789012 | | DeploymentTarget | r-abcd | | StackSetName | LevelFour-Onboarding | | RoleNameInEachAccount | LevelFourCrossAccountRole | ----------------------------------------------------------- Active organization accounts and their LevelFour role status: Account ID Name Role Status -------------- -------------------------------- --------------- 123456789012 Org (management) CURRENT 234567890123 Prod CURRENT 345678901234 Staging CURRENT 456789012345 Dev CURRENT ================ Send this to LevelFour ================ ManagementAccountId : 123456789012 Active accounts : 4 Role deployed to : 4 of 4 ======================================================== ``` > **Success** > > Every row `CURRENT`, and a summary whose two account counts match, means the role is live in every active account in your organization. That is the whole check. If any account is not `CURRENT`, the script flags it and explains the common causes: the [management account missing on older deploys](#upgrading-an-older-wrapper), or an [SCP block](#troubleshooting). **CloudFormation console** The console does not surface the full account list in one place. CloudFormation outputs are stack-level only, and aggregating AWS Organizations data would need a custom Lambda resource, which the template does not include. You will need three short stops. **Stop 1. Wrapper stack outputs.** Open the wrapper stack (`LevelFour-Onboarding`) > **Outputs** tab: | Output | What it is | | ----------------------- | ---------------------------------------------------------------------------------- | | `ManagementAccountId` | Your AWS Organizations management account ID, the value to send LevelFour | | `DeploymentTarget` | The root ID or OU ID you deployed to (root = every account) | | `StackSetName` | `LevelFour-Onboarding`, useful if you ever need to manage the StackSet later | | `RoleNameInEachAccount` | `LevelFourCrossAccountRole`, the deterministic role name in every targeted account | **Stop 2. Member account rollout status.** CloudFormation > **StackSets** > `LevelFour-Onboarding` > **Stack instances** tab. One row per member account. Every row should show `Status: CURRENT`. The management account is not in this table. It is covered by the inline `LevelFourCrossAccountRole` resource on the wrapper stack itself, which is already in `CREATE_COMPLETE` if you got this far. **Stop 3. All organization accounts.** AWS Organizations console > **Accounts**. Cross-reference these with the StackSet's Stack instances table. Every active account should be either a row in Stop 2 (member, rolled out via StackSet) or the management account (covered by the wrapper). The **Handoff script** tab does all three stops in one command, and prints an explicit "Send this to LevelFour" summary. **AWS CLI** The same data the handoff script consolidates, raw: ```bash aws cloudformation describe-stacks \ --stack-name LevelFour-Onboarding \ --region us-east-1 \ --query 'Stacks[0].Outputs[].[OutputKey,OutputValue]' \ --output table aws cloudformation list-stack-instances \ --stack-set-name LevelFour-Onboarding \ --region us-east-1 \ --query 'Summaries[].[Account,Region,Status]' \ --output table aws organizations list-accounts \ --query 'Accounts[?Status==`ACTIVE`].[Id,Name]' \ --output table ``` * The first command prints the wrapper stack outputs (your `ManagementAccountId` lives there). * The second prints per-member-account rollout status from the StackSet. Every row should show `Status: CURRENT`. Any account in a `FAILED` or `INOPERABLE` state needs a per-account look, usually an SCP block. See [Troubleshooting](#troubleshooting). * The third prints every active account in your organization, **including the management account**, which is never in the StackSet's stack-instance list. It is covered by the inline `LevelFourCrossAccountRole` resource on the wrapper stack itself, which is already in `CREATE_COMPLETE`. For the unified view the handoff script prints on its own, cross-reference the second and third tables. Every active account ID from the third should appear in the second with `Status: CURRENT`, except the management account. ## Verifying a specific account Each member account's CloudFormation console shows its own stack, with the same `Outputs.RoleArn` you would see on the single-account path. To confirm by hand that an account was reached, sign into that account, open CloudFormation > **Stacks**, find the stack named `StackSet-LevelFour-Onboarding-...`, and check its Outputs. ## What happens when new accounts join your organization Because `AutoDeployment` is enabled, CloudFormation automatically deploys the role to any new account that joins your target. LevelFour re-syncs periodically, usually daily, and adds those new accounts to your organization in LevelFour. ## Upgrading an older wrapper Wrapper templates published before May 2026 did not create the LevelFour role in your AWS Organizations management account. They only covered member accounts via the StackSet. If your handoff script shows the management account row as `NOT_DEPLOYED` and every other row is `CURRENT`, you are on an older wrapper. To upgrade in place: 1. Open the CloudFormation console > **Stacks** > click your wrapper stack (default name `LevelFour-Onboarding`) 2. **Stack actions** > **Update stack** 3. **Replace current template** > **Amazon S3 URL**, paste: ```text https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-stackset-wrapper.template.json ``` 4. Click through. Your existing parameters carry over, so leave them as is. 5. Tick the IAM acknowledgement > **Update stack** The update is non-destructive for member accounts, because the StackSet is untouched. It just adds the inline IAM role for the management account. After `UPDATE_COMPLETE`, re-run the handoff script and the management account row should show `CURRENT`. ## Removing access To revoke LevelFour's access across all accounts, delete the wrapper stack from the management account. CloudFormation will tear down the StackSet, which removes the role from every member account. ```bash aws cloudformation delete-stack --stack-name LevelFour-Onboarding ``` To remove access from a single account, move that account out of the targeted OU. CloudFormation auto-removes the role when the account leaves. ## Troubleshooting ### StackSet OPERATION_FAILED for one account, others succeeded Open the StackSet > **Operations** > click the failed operation > review the per-instance error. Most common causes: * An SCP on that account's OU denies `iam:CreateRole` * The account is suspended (`Status != ACTIVE` in Organizations) * A role named `LevelFourCrossAccountRole` already exists in that account from a previous single-account deploy. Delete the old role, then retry ### Deploy fails with 'You must enable organizations access to operate a service managed stack set' You enabled Organizations-side trusted access but not the CloudFormation-side toggle. These are separate per-region settings. Run `aws cloudformation activate-organizations-access --region us-east-1` and retry the StackSet deployment. Re-running the preflight script will confirm both sides are on. ### The management account shows NOT_DEPLOYED You are on a wrapper published before May 2026. See [Upgrading an older wrapper](#upgrading-an-older-wrapper). ### Trusted access keeps showing as disabled even after I enabled it Wait 30 seconds for the change to propagate and refresh the page. If it still shows disabled, you may have enabled it in a different organization. Confirm the account selector matches your management account. ### The Launch Stack link opens but the parameters are blank That is expected on the manual path. The Launch Stack URL leaves `LevelFourHandshakeID` and `TargetOUId` blank for you to fill in. Paste the handshake LevelFour provided and the target ID (root or OU) from the preflight output. The dashboard pre-fills both for you. ### New accounts are not getting the role Confirm the new account is in the OU you targeted (not the root, unless you targeted the root). It can take a few minutes for AWS to propagate. If still missing after 30 minutes, check the StackSet's **Stack instances** tab for the account ID. ### LevelFour cannot find an account that was onboarded The account is likely suspended in Organizations, which causes `organizations:ListAccounts` to skip it. Re-activate the account and let LevelFour's re-sync pick it up, typically within 24 hours. Or ask your LevelFour contact to trigger a manual sync. ## Next * [Detailed cost data](/connectors/aws/detailed-cost-data) turns on the Cost and Usage Report in your management account, which is what gives you resource-level and tag-level breakdowns * [Automated Savings](/connectors/aws/automated-savings) covers the `LevelFourSavingsExec` role across an organization, including the management account the StackSet does not reach --- # Detailed cost data Source: https://docs.levelfour.ai/connectors/aws/detailed-cost-data.md LevelFour can read your AWS **Cost and Usage Report 2.0** (CUR), the most detailed billing data AWS produces. Turn it on and LevelFour breaks costs down to the individual resource, tag, and usage type, and shows amortized and net costs alongside unblended spend. LevelFour copies each day's report into its own storage and refreshes your cost views. The step is optional and you do it once. Your account works without CUR because LevelFour falls back to Cost Explorer, but resource-level views and tag-based cost allocation are richer once CUR is on. On Google Cloud the equivalent is [Cloud Billing export](/connectors/gcp/billing-export). > **Warning** > > Run this in your **management (payer) account**, in **us-east-1**. The Cost and Usage Report is an organization-wide artifact: one export in the payer account covers every member account, so you do not run it in linked accounts. ## What gets created The stack creates three resources in your account: * An S3 bucket named `levelfour-cur-` that receives the export. It is SSE-S3 encrypted, has public access blocked, and expires objects after 120 days. * A bucket policy that lets the AWS Data Exports service write the report and lets LevelFour read it. AWS Data Exports cannot read or delete anything in the bucket, and LevelFour has read-only access. * A CUR 2.0 data export (`AWS::BCMDataExports::Export`) that delivers a daily Parquet file with all available columns and resource IDs. The export itself is free. You pay only standard S3 storage for the bucket, which is typically a few dollars per month. ## Turn on the export Use the AWS Console path unless you manage this account as code. **AWS Console** Sign in to the AWS Console, then open the one-click stack link: [Create the CUR stack](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-enable-cur.template.json\&stackName=LevelFour-CUR) 1. Confirm you are in the **management (payer) account** and the region is **us-east-1**. 2. Leave the parameters at their defaults. 3. Acknowledge the capabilities prompt and click **Create stack**. The stack finishes in under a minute. AWS then delivers the first report within about a day. **Terraform** Deploy the same published template through an `aws_cloudformation_stack` resource, from the management account in `us-east-1`. This keeps a single source of truth (the LevelFour template) and records the export in your state. ```hcl title="levelfour-cur.tf" resource "aws_cloudformation_stack" "levelfour_cur" { name = "levelfour-cur" template_url = "https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-enable-cur.template.json" } ``` Apply it: ```bash terraform init terraform apply ``` If you would rather not wrap a CloudFormation stack, you can create the same resources natively with `aws_s3_bucket`, `aws_s3_bucket_policy`, and `aws_bcmdataexports_export`. > **Tip** > > The wrapped template is simpler and stays in sync with LevelFour automatically, so prefer it unless your platform standards require native resources. ## Confirming it worked Open **Connect Providers**, then **AWS** in the dashboard. The **Detailed cost data** panel summarizes which payer accounts are connected. > **Success** > > Within about a day of the first delivery, your management account shows as connected there, and resource-level breakdowns appear in your cost views. > **Error** > > If it stays disconnected after a day, confirm the stack was created in the **management account** in **us-east-1**, and that no Service Control Policy blocks `bcm-data-exports:CreateExport` or the bucket policy. ## Next * [Automated Savings](/connectors/aws/automated-savings) covers the `LevelFourSavingsExec` role, which is what lets LevelFour apply an approved change * [Costs](/resources/costs) is how cost data comes back over the API, with breakdowns by service, account and time period --- # Automated Savings Source: https://docs.levelfour.ai/connectors/aws/automated-savings.md Automated Savings attaches a scoped, time-limited IAM policy to a dedicated role named `LevelFourSavingsExec` whenever you approve a specific savings action, then removes it when the action finishes. Accounts onboarded before this role existed do not have it. You enable it once per account by creating the empty role. Accounts onboarded with the current templates already have it, and need nothing here. The role is created empty, with no attached permissions, and it trusts only LevelFour's AWS account (`730335248431`), gated by your handshake ID. See [what the onboarding stack creates](/connectors/aws/single-account#what-the-stack-creates) for the full trust model. > **Note** > > However you create the `LevelFourSavingsExec` role (AWS Console, Terraform, a CloudFormation StackSet, or by hand in IAM), LevelFour detects it automatically and clears the update prompt in the dashboard. You do not need to use the in-app link or notify anyone. ## One account Pick the route that matches how you manage the account. Both end at the same empty role. **AWS Console** The fastest path for a single account is the dashboard. Go to **Connect Providers > AWS** (`https://dashboard.levelfour.ai/connect-accounts/aws`). If any connected account is missing the role, an "update available" notice appears with an **Update via AWS Console** button that opens CloudFormation with the enablement template pre-loaded. You can also skip the dashboard. Sign in to the account you want to enable, then use the Launch Stack link: ```text https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-enable-savings.template.json&stackName=LevelFour-Enable-Savings¶m_LevelFourHandshakeID= ``` Enter your **LevelFourHandshakeID** (the value LevelFour gave you), tick the IAM capability acknowledgement, and click **Create stack**. The stack creates only the empty `LevelFourSavingsExec` role and finishes in about 30 to 60 seconds. > **Note** > > This stack only adds the role, so it is safe to run in an account that is already connected to LevelFour. > **Error** > > If CloudFormation reports that `LevelFourSavingsExec` already exists, that account is already enabled and there is nothing to do. **Terraform** Add the role to the configuration you already use for this account. ```hcl title="levelfour-savings-exec.tf" variable "levelfour_handshake_id" { type = string description = "The handshake ID LevelFour provided. Treat it like a secret." } resource "aws_iam_role" "levelfour_savings_exec" { name = "LevelFourSavingsExec" path = "/" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { AWS = "arn:aws:iam::730335248431:root" } Action = "sts:AssumeRole" Condition = { StringEquals = { "sts:ExternalId" = var.levelfour_handshake_id } } }] }) } ``` Apply it the same way you manage the rest of the account. LevelFour picks up the role on its next probe. ## Across an AWS Organization Act only if your organization was onboarded before the role was added to the per-account onboarding template. In that case, redeploy the current onboarding template to your member accounts. If you created the StackSet directly with the AWS CLI, update it in place. This re-fetches the current template and rolls it out to every targeted account: ```bash aws cloudformation update-stack-set \ --stack-set-name LevelFour-Onboarding \ --template-url https://cf-templates-1bsphth8u70q9-us-east-1.s3.amazonaws.com/onboarding/v2/onboarding-levelfour-cloudformation-template.json \ --capabilities CAPABILITY_NAMED_IAM \ --parameters ParameterKey=LevelFourHandshakeID,UsePreviousValue=true ``` If you onboarded with the Launch Stack wrapper, manage the rollout through that wrapper stack rather than updating the StackSet directly, to avoid drift. See [multi-account onboarding](/connectors/aws/multi-account) for the StackSet setup, prerequisites, and template URLs. > **Warning** > > The management account is covered separately. AWS Organizations excludes it from service-managed StackSet targeting, so enable it with the [one account](#one-account) routes above. > **Tip** > > Prefer Terraform for your organization? Add the `aws_iam_role` from the Terraform tab above to the per-account module or StackSet template you already use to manage `LevelFourCrossAccountRole`. ## Confirming it worked Once the role exists, open **Connect Providers > AWS** in the dashboard. > **Success** > > The update notice and the header indicator clear on their own within about a minute. Automated Savings actions that previously reported "not enabled" will then run. Use **Re-check** on the notice if you want to confirm immediately after applying with Terraform or the CLI. For an organization rollout, [multi-account onboarding](/connectors/aws/multi-account) covers reading the StackSet's per-account rollout status. ## Removing access Deleting the stack (or StackSet) that created the role removes it. If you manage it in Terraform, `terraform destroy` does the same. Deleting the empty role has no effect on the read-only `LevelFourCrossAccountRole` that LevelFour uses to analyze costs. ## Next * [What the onboarding stack creates](/connectors/aws/single-account#what-the-stack-creates) is the full trust model for both roles * [Multi-account onboarding](/connectors/aws/multi-account) is the StackSet setup, prerequisites and template URLs * [`l4 rec execute`](/cli/commands/recommendations/execute) is what an approved action runs --- # Connect projects Source: https://docs.levelfour.ai/connectors/gcp.md LevelFour connects to Google Cloud in two parts. First you grant read-only access to the projects you want analyzed by running a short command in your own Cloud Shell. Then you connect your Cloud Billing data so LevelFour can attribute spend to individual resources. Different people can own each part. Connecting projects needs someone who can grant IAM roles; connecting billing data needs a finance or billing admin. Do them in that order, because billing export on its own produces nothing: LevelFour only reads costs for projects you have already connected. ## Prerequisites * **Ask your LevelFour contact to turn on Google Cloud for your organization.** Until they do, the connect commands cannot be generated. * LevelFour admin rights, to generate the connect commands. * Someone who can grant IAM roles on the target project, typically an Owner or a user with `roles/resourcemanager.projectIamAdmin`. For organization mode, the same rights at the organization level, for example `roles/resourcemanager.organizationAdmin`. * The connect command from the **Connect Google Cloud** page in the dashboard. Connecting billing data needs a finance identity as well, with its own roles. [Cloud Billing export](/connectors/gcp/billing-export#prerequisites) lists them. ## Connect ### Choose single project or organization Open the **Connect Google Cloud** page in the dashboard (**Connect Providers > Google Cloud**). A toggle sets the scope of the connection. Pick one: * **Single Project** grants read access to one project. * **Organization** discovers every project under your Google Cloud organization and connects them in one pass, so existing projects and projects that join later are all covered. The two paths do not combine, so follow the one you picked start to finish. > **Note** > > A single-project connection ingests only that project's share of the bill, never the whole billing account. > **Warning** > > A single-project connection cannot use the Committed Use Discounts export, which requires an organization connection. If you hold committed use discounts, connect at the organization level. ### Point Cloud Shell at the right scope The connect script reads the scope from your Cloud Shell session, not from the dashboard. Set it before you run the command. Run the commands for the mode you picked above, not both. **Single Project** Set the active project to the one you want to connect: ```bash gcloud config set project YOUR_PROJECT_ID gcloud config get-value project # confirm ``` **Organization** Nothing to set. If your identity can see more than one organization, the script lists them and asks which one to connect: ```text Your gcloud identity sees more than one organization: 1) organizations/111111111111 Acme 2) organizations/222222222222 Acme Labs Which organization? [1-2] ``` Type the number of the one you want. With a single organization, the script connects it without asking. To choose up front instead, run `export LEVELFOUR_ORG_ID=YOUR_ORG_ID` in Cloud Shell before you paste the command. To connect several, see [Connect more than one organization](#connect-more-than-one-organization). ### Run the connect command in Cloud Shell Click **Generate connect command**. LevelFour builds the command, embeds a one-time token tied to your organization, and shows it with a **Copy** button. Open Cloud Shell in the Google Cloud console (the terminal icon in the top bar), paste the command, and run it. Copy it exactly as shown; there is nothing in it for you to fill in or edit. Read [What the connect command does](#what-the-connect-command-does) first if you want to see the script before you run it. The script prints the service-account address, the identity it will grant as, the `Scope:` it will grant on, and the list of roles, then asks `Continue? [y/N]`. Answer `y`. Anything else stops the script without changing your account. > **Note** > > Copy the service-account address the script prints. It looks like `tenant-42@levelfour-prod.iam.gserviceaccount.com`. The optional steps further down this page need it, and no dashboard screen shows it. If you lose it, see [Finding your service-account address](#finding-your-service-account-address). The roles it grants are read-only: | Role | What it allows | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `roles/browser` | Read the resource hierarchy (projects, folders, organization) so LevelFour can enumerate what exists | | `roles/monitoring.viewer` | Read Cloud Monitoring metrics, used to detect idle resources from real usage | | `roles/compute.viewer` | Read Compute Engine resources: instances, disks, addresses, snapshots | | `roles/cloudasset.viewer` | Read the Cloud Asset Inventory, a normalized snapshot of resources across the connected scope | | `roles/serviceusage.serviceUsageConsumer` | Call the read-only Google Cloud APIs above against your project | | `roles/recommender.computeViewer` | Read Google's own recommendation data for Compute Engine | | `roles/recommender.cloudsqlViewer` | Read Google's own recommendation data for Cloud SQL | | `roles/recommender.projectCudViewer` | Read committed use discount recommendations at the project level | | `roles/cloudsql.viewer` | List Cloud SQL instances, so LevelFour can spot idle databases | | `roles/logging.viewer` | Read Cloud Audit Admin Activity logs, so an applied change is attributed to whoever made it | | `roles/dns.reader` | Read Cloud DNS records, so LevelFour skips an unused IP address that a DNS record still points at | The script grants each role separately and keeps going if one fails, so a partial grant is normal. LevelFour does not take the script's word for it: it verifies the connection by checking what the service account can see. A run where every role succeeded still fails verification if the service account sees no projects. Billing data is not covered by these roles. You connect it separately, in [Connect your billing data](#connect-your-billing-data). > **Warning** > > If your organization enforces **Domain Restricted Sharing** (the `iam.allowedPolicyMemberDomains` org policy), IAM bindings to an outside identity are blocked by default and the grant will fail. Ask your LevelFour contact for the Cloud Identity customer ID to allowlist, add it to the allowed domains (or add an exception for the LevelFour service account), then re-run the command. Your connect command stays valid for 20 minutes. Within that window you can re-run it as often as you need. After it expires, click **Generate connect command** again for a fresh one. ### Check what connected > **Success** > > Within about a minute of the command finishing, the **Connect Google Cloud** page moves through **Waiting for grant**, then **Verifying access**, then **Connected**, and what you connected appears in the **Projects** table below it. A project LevelFour can read shows **Connected**. If it stalls on **Waiting for grant** for more than 90 seconds, the page offers to cancel and generate a fresh command. The **Projects** table lists what LevelFour found, one row per project: | Status | Meaning | | -------------- | ------------------------------------- | | Connected | LevelFour can read this project | | Awaiting grant | The binding has not landed yet | | Error | LevelFour could not read this project | | Revoked | Access was removed | In organization mode LevelFour re-discovers projects daily, so projects that join your organization later are picked up on their own. ### Enable the APIs LevelFour reads LevelFour analyzes a project only where the APIs it reads are enabled. Enable these on each project you want covered: * `compute.googleapis.com` (Compute Engine) * `monitoring.googleapis.com` (Cloud Monitoring) * `cloudresourcemanager.googleapis.com` (Cloud Resource Manager) * `sqladmin.googleapis.com` (Cloud SQL Admin), if you run Cloud SQL * `bigquery.googleapis.com` (BigQuery), on the project that holds your billing export ### Connect your billing data LevelFour reads your Cloud Billing data from a BigQuery billing export. > **Warning** > > LevelFour prices every finding against your bill and drops any it cannot price, so until the export lands you get no Google Cloud recommendations. Follow [Cloud Billing export](/connectors/gcp/billing-export). That page walks through turning on the Detailed usage cost export, choosing a dataset, and running a second command that grants LevelFour read access to the export. ## Confirming it worked The project side confirms in about a minute. See [Check what connected](#check-what-connected). The billing side takes about two days end to end: Google takes 24 to 48 hours to deliver the first export rows, and LevelFour reads the export on a schedule after that. Once that data lands, your cost views and the **Google Cloud** board fill in, showing cost after credits unless you switch the board to **Before credits**. [Google Cloud credits](/resources/costs#google-cloud-credits) covers the two figures. The export page covers what the **Billing data (BigQuery export)** panel shows: [Confirming it worked](/connectors/gcp/billing-export#confirming-it-worked). Your LevelFour contact turns on the rest per organization: the resource inventory, utilization metrics, budgets, and the access check that shows which grants landed. Each one reads through roles your two connect commands already granted, so you run nothing extra. Budgets need the optional `roles/billing.viewer` from the [billing connect command](/connectors/gcp/billing-export#connect-the-export-to-levelfour). Cost anomalies need about two weeks of cost history before the first one can appear, and LevelFour only raises anomalies above a $50 impact. ## Connect more than one organization One LevelFour organization can hold several Google Cloud organizations. You connect each one with its own connect command, and all of them grant to the same service account. 1. On the **Connect Google Cloud** page, keep **Organization** selected and click **Generate connect command** again. The projects you already connected stay connected. 2. Run the new command in Cloud Shell. When the script asks which organization to connect, pick the new one. 3. If the new organization pays through a billing account you have not connected yet, run the [billing connect command](/connectors/gcp/billing-export#connect-the-export-to-levelfour) and pick that account when the script asks. A billing account you already connected picks up the new projects on its own. LevelFour re-discovers projects in every connected organization daily. > **Warning** > > Connect every organization at the organization level. If any connection is a single project, LevelFour reads only your connected projects' rows from each billing export. Charges outside those projects, such as Marketplace subscriptions and commitment fees, then drop out of your costs. See [Committed use discounts](/connectors/gcp/committed-use-discounts). ## What the connect command does You grant access to a service account that belongs only to your organization. The command downloads a script to `levelfour_connect.sh` rather than piping it into a shell, so you can read it before it runs: ```bash less levelfour_connect.sh ``` LevelFour never asks for a service-account key, and no credentials leave your environment. The script adds the read-only bindings listed under [Run the connect command in Cloud Shell](#run-the-connect-command-in-cloud-shell), reports back to LevelFour, and stops there. The one-time token travels in the `Authorization` header, not the URL. The downloaded file contains that token, so delete it when you are done. ## Finding your service-account address The connect script prints the address it grants to, and that is the only place it appears. If you no longer have the output, read it back from the bindings the script created: ```bash gcloud projects get-iam-policy YOUR_PROJECT_ID \ --flatten="bindings[].members" \ --filter="bindings.members:levelfour-prod.iam.gserviceaccount.com" \ --format="value(bindings.members)" | sort -u ``` If you connected an organization, swap `projects get-iam-policy YOUR_PROJECT_ID` for `organizations get-iam-policy YOUR_ORG_ID`. Generating a fresh connect command and running it again also reprints the address. ## Removing access Revoking has two parts, one for each connect command. **Project or organization roles.** From Cloud Shell, for a single project: ```bash SA="PASTE_THE_ADDRESS_THE_SCRIPT_PRINTED" for ROLE in roles/browser roles/monitoring.viewer roles/compute.viewer \ roles/cloudasset.viewer roles/serviceusage.serviceUsageConsumer \ roles/recommender.computeViewer roles/recommender.cloudsqlViewer \ roles/recommender.projectCudViewer roles/cloudsql.viewer \ roles/logging.viewer roles/dns.reader; do gcloud projects remove-iam-policy-binding "YOUR_PROJECT_ID" \ --member="serviceAccount:$SA" --role="$ROLE" done ``` If you connected an organization, swap `projects remove-iam-policy-binding "YOUR_PROJECT_ID"` for `organizations remove-iam-policy-binding "YOUR_ORG_ID"`. **Billing access.** Remove the billing-account roles and the BigQuery dataset binding. See [Removing billing access](/connectors/gcp/billing-export#removing-access). LevelFour stops being able to read as soon as the bindings are gone. To also have your Google Cloud records removed from LevelFour, contact your LevelFour representative. ## Troubleshooting **`gcloud is required. Open this in a Cloud Shell.`** The script ran somewhere other than Cloud Shell. Open Cloud Shell from the terminal icon in the Google Cloud console and paste the command there. **The command fails with a policy or permission error on the IAM binding.** Your organization likely enforces Domain Restricted Sharing. Ask your LevelFour contact for the Cloud Identity customer ID to allowlist, add it, then re-run the command. **The command reports success but the page never leaves Waiting for grant.** The script finished but its report to LevelFour did not arrive. Re-run the command; it is safe to run twice. If your command has expired, reopen the Connect Google Cloud page for a fresh one. **Verification fails with no projects visible.** The roles were bound somewhere the service account cannot see any projects. In single-project mode, confirm `gcloud config get-value project` returned the project you meant. In organization mode, confirm the `Scope:` line the script printed names the organization you meant. **Organization mode connected fewer projects than you expected.** LevelFour lists the projects your organization exposes to the service account, so projects pending deletion are skipped. If a project you expect is missing, confirm it sits under the organization you connected and re-check after the next daily discovery. **Recommendations are missing entirely.** Cost lives in the billing export. Until it lands, LevelFour has nothing to price findings against. See [Connect your billing data](#connect-your-billing-data). **Applied savings show an unknown actor.** Attribution reads your Cloud Audit Admin Activity logs through `roles/logging.viewer`. Check the connect command's output to see whether that grant succeeded, and re-run the command if it did not. ## Next * [Billing export](/connectors/gcp/billing-export) turns on the Detailed usage cost export, which is what prices every recommendation against your real bill * [Committed use discounts](/connectors/gcp/committed-use-discounts) fills in the commitment term, start and end dates, and status alongside coverage --- # Billing export Source: https://docs.levelfour.ai/connectors/gcp/billing-export.md LevelFour reads your **Cloud Billing** data through the BigQuery billing export, the most detailed billing data Google Cloud produces. It breaks costs down to the individual resource, label, and SKU, and prices every recommendation against what you were billed rather than list rates. Set it up once per Cloud Billing account, in the Cloud Billing console. Billing export is billing-account scoped, so a single export covers every project that pays through that billing account. You do not configure it per project. > **Warning** > > Connect your projects first. Follow [Connect Google Cloud](/connectors/gcp) before this page. LevelFour reads costs only for projects you have already connected, so an export connected on its own produces no cost data. > **Note** > > An organization connection ingests every connected project's costs from the export. A single-project connection ingests only that one project's costs, even though the export itself contains the whole billing account. ## What you enable You enable the **Detailed usage cost export**: one row per SKU per resource per day, with resource-level detail and labels. The export lands in a table named `gcp_billing_export_resource_v1_` inside a BigQuery dataset you control. > **Warning** > > Enable the **Detailed** export, not the Standard one. The Standard usage cost export drops the per-resource rows that LevelFour needs to attribute cost to individual resources. A Standard export connects, but it produces no savings recommendations and no realized savings. Google Cloud offers two further exports on the same screen: * **Committed Use Discounts.** Enable it if you hold committed use discounts and connected at the organization level. See [Committed Use Discounts export](/connectors/gcp/committed-use-discounts). * **Pricing.** LevelFour reads it from the same dataset as the Detailed export. It holds your list rate for each SKU. Savings figures come from your bill, not from these rates. ## What it costs The export itself is free. You pay only BigQuery storage for the export table: the first **10 GB per month is free**, and beyond that BigQuery active storage is about **$0.02 per GB per month**. LevelFour's queries run in LevelFour's own Google Cloud project, so the cost of reading the export is on LevelFour, not on you. Costs in a non-USD billing account are converted to USD using the conversion rate Google includes in each export row. Everything you see in LevelFour is in USD. ## Prerequisites * A finance or billing identity with **Billing Account Administrator** or **Costs Manager** on the Cloud Billing account you want to export. * **BigQuery Admin** (or an equivalent) on the Google Cloud project that will hold the export dataset, so the console can create the dataset and the connect command can update its access list. These are often different people from whoever connected your projects. Billing-account IAM admin rights are useful but not required. Without them the connect command still grants LevelFour read access to your export dataset. It skips two optional billing-account roles and prints which ones. ## Set up the export ### Create a BigQuery dataset In the project that will hold the export, create a BigQuery dataset (the Cloud Billing setup can also create one for you). A common name is `billing_export`. You cannot change the location later, so choose it now: * Use a **US** or **EU** multi-region unless you have a specific data-residency requirement. Multi-region is the most broadly compatible location and matches how LevelFour reads the data. * You cannot move a dataset after it is created, so if you pick the wrong location you have to make a new dataset and reconfigure the export. Note which project holds this dataset. You need it when you run the connect command. ### Turn on the Detailed usage cost export 1. Open the **Cloud Billing** console and select the billing account you want to export. 2. Go to **Billing export**, then the **BigQuery export** tab. 3. Under **Detailed usage cost**, click **Edit settings**, choose the project and dataset from the previous step, and save. > **Note** > > The Standard and Detailed tables can share a dataset. When both are there, the connect command picks the Detailed one. ### Wait for the first data Google Cloud writes the first rows **24 to 48 hours** after you enable the export. When the export starts, it also backfills recent history, filling in **up to 5 days** of prior usage, so early cost views populate retroactively. > **Note** > > Until the first load lands, the dataset exists but its tables are empty. That is expected. ### Connect the export to LevelFour Open the **Connect Google Cloud** page in the dashboard and find the **Billing data (BigQuery export)** panel. Click **Generate billing connect command**, then **Copy**. This is a different command from the one that connected your projects. Paste it as shown; there is nothing in it for you to fill in. > **Warning** > > Run this from a Cloud Shell whose active project is the project that holds your export dataset. The script searches that project only. Set it first: > > ```bash > gcloud config set project YOUR_EXPORT_PROJECT_ID > ``` The script needs `gcloud`, `bq`, and `python3`, all of which Cloud Shell provides. If your identity can see more than one billing account, the script lists them and asks `Which billing account?`. Type the number of the account whose export lives in this project. To choose up front instead, run `export LEVELFOUR_BILLING_ACCOUNT_ID=YOUR_BILLING_ACCOUNT_ID` before you paste the command. To connect several billing accounts, run the command once for each. The script then finds that account's export table and prints its name. A name starting with `gcp_billing_export_resource_v1_` is the Detailed export. A name starting with `gcp_billing_export_v1_` means the script found only the Standard export, so stop and turn on the Detailed one first. Then the script asks you two questions: 1. `Continue? [y/N]` before granting `roles/bigquery.dataViewer` on the export dataset to your service account. Answer `y`. 2. `Cap this export table to 92 days to minimize BigQuery storage cost? LevelFour keeps full history. [y/N]` Answer `y` to bound your BigQuery storage, or press Enter to leave your table's retention alone. LevelFour re-reads only the recent window and keeps full history in its own database, so capping loses you no analysis. Anything other than `y` or `Y` counts as no. The dataset grant is already applied by the time the second question appears, so declining the cap does not undo the connection. To set the expiration yourself instead, see [Capping storage yourself](#capping-storage-yourself). The script then tries to grant two optional read roles on the billing account itself, `roles/billing.viewer` and `roles/recommender.billingAccountCudViewer`. The first one also lets LevelFour read your budgets. Both need billing-account IAM admin. If you do not have it the script prints `Could not grant (needs billing-account IAM admin); skipping.` and carries on. Finally it reports the dataset and table back to LevelFour, which confirms the service account can read the export before marking it connected. The command stays valid for 30 days and is safe to re-run. ## Capping storage yourself If you would rather set the partition expiration before you connect, or your finance team manages BigQuery retention centrally, run it yourself once the table exists: ```bash bq update --time_partitioning_expiration 7948800 \ YOUR_PROJECT_ID:billing_export.gcp_billing_export_resource_v1_YOUR_BILLING_ACCOUNT_ID ``` `7948800` seconds is 92 days. ## Confirming it worked Open **Connect Providers > Google Cloud** in the dashboard. The **Billing data (BigQuery export)** panel summarizes which billing accounts are connected. > **Success** > > Within about two days of enabling the export, your billing account shows as connected and resource-level cost breakdowns appear in your cost views. ## Export status reference The **Billing data**, **Committed Use Discounts**, and **Pricing** panels all use the same status labels: | Status | Meaning | | ----------------- | ------------------------------------------------------------------------------------------- | | Not connected yet | LevelFour has no access to this export. Run the connect command, or enable the export first | | Setting up | LevelFour has access and is waiting for the first data to land | | Connected | LevelFour is reading the export. The panel shows when it last synced | | Action needed | Something on your side is blocking the read, usually a missing grant or a missing export | | Sync error | The last read failed. The panel shows the reason Google Cloud returned | If you have several billing accounts, the panel shows the least-ready status across all of them, so a partially connected setup never reads as fully connected. ## Removing access Remove the dataset access entry and the two billing-account roles. Use the address the connect script printed; see [Finding your service-account address](/connectors/gcp#finding-your-service-account-address) if you no longer have it. ```bash SA="PASTE_THE_ADDRESS_THE_SCRIPT_PRINTED" # Drop the dataset read grant bq show --format=prettyjson YOUR_PROJECT_ID:billing_export > policy.json # remove the entry whose "userByEmail" is the service-account address, then: bq update --source policy.json YOUR_PROJECT_ID:billing_export # Drop the billing-account roles, if they were granted for ROLE in roles/billing.viewer roles/recommender.billingAccountCudViewer; do gcloud billing accounts remove-iam-policy-binding YOUR_BILLING_ACCOUNT_ID \ --member="serviceAccount:$SA" --role="$ROLE" done ``` To also remove the project and organization roles, see [Removing access](/connectors/gcp#removing-access). ## Troubleshooting ### No BigQuery billing export for billing account `No BigQuery billing export for billing account in project .` Either the export is not on yet, its first data has not landed yet, your Cloud Shell is pointed at a different project than the one holding the dataset, or you picked a billing account that exports somewhere else. Run `gcloud config set project YOUR_EXPORT_PROJECT_ID`, run the command again, and pick the billing account that exports into that project. ### No billing account is visible to your gcloud identity The identity running the command cannot see any billing account. Run `gcloud billing accounts list` to confirm, and use an identity with **Billing Account Administrator** or **Costs Manager**. ### bq (the BigQuery CLI) is required `bq (the BigQuery CLI) is required. Open this in a Cloud Shell.` Run the command in Cloud Shell, which ships `gcloud`, `bq`, and `python3`. ### The script stops on a bq error and the panel stays at Not connected yet A failed read or update on the dataset stops the script before it reports back, so LevelFour never hears about the connection. Confirm you have **BigQuery Admin** on the project holding the dataset, then re-run. ### The dataset tables are still empty after two days Confirm the Detailed usage cost export is enabled on the correct billing account, that the dataset is in a US or EU multi-region, and that the billing service has write access to the dataset. The first export can take the full 24 to 48 hours. ### You get a permission error when enabling the export You need **Billing Account Administrator** or **Costs Manager** on the billing account, and **BigQuery Admin** on the project that holds the dataset. Have whoever owns billing complete this step. ### The panel shows Connected, but there are no savings recommendations Check that you enabled the **Detailed** export, not the Standard one. Only the Detailed export carries the per-resource rows LevelFour needs. If you connected the Standard export, turn on the Detailed one, wait for its first rows, then run the connect command again. LevelFour switches to the Detailed table. ### The command fails on the IAM binding If your organization enforces **Domain Restricted Sharing** (`iam.allowedPolicyMemberDomains`), the policy blocks the grant until you allowlist LevelFour's Cloud Identity customer ID. See the Domain Restricted Sharing note in [Connect Google Cloud](/connectors/gcp#run-the-connect-command-in-cloud-shell). ## Next * [Committed use discounts](/connectors/gcp/committed-use-discounts) is the second export, which fills in commitment term, dates and status * [Costs](/resources/costs) is how the ingested cost data comes back over the API --- # Committed use discounts Source: https://docs.levelfour.ai/connectors/gcp/committed-use-discounts.md Google Cloud publishes the lifecycle of your committed use discounts in a separate BigQuery export. Enabling it fills in the commitment term, start and end dates, and status on your Google Cloud commitments. Skip this page if you hold no committed use discounts. Everything else about commitments, including coverage and utilization, already works from the [Detailed usage cost export](/connectors/gcp/billing-export). > **Warning** > > This export only works with an **organization** connection. If any of your Google Cloud connections is a single project, LevelFour keeps commitment lifecycle data off, because the export covers the whole billing account and cannot be narrowed to one project. Connect at the organization level first. See [Connect Google Cloud](/connectors/gcp#choose-single-project-or-organization). > **Note** > > Google lists this export as Preview. Its fields can change, and the data you see may lag or shift as Google finishes the feature. ## What you get | Field | Source | | -------------------------------- | -------------------------- | | Commitment term (1 year, 3 year) | This export | | Start and end dates | This export | | Commitment status | This export | | Coverage and utilization | Detailed usage cost export | | Spend against the commitment | Detailed usage cost export | ## Prerequisites * An **organization** connection to LevelFour, with no single-project connections alongside it. * The [Detailed usage cost export](/connectors/gcp/billing-export) already connected. * **Billing Account Administrator** or **Costs Manager** on the Cloud Billing account. * **BigQuery Admin** on the project that holds your billing export dataset. ## Set up the export ### Turn on the export 1. Open the **Cloud Billing** console and select your billing account. 2. Go to **Billing export**, then the **BigQuery export** tab. 3. Under **Committed use discounts**, click **Edit settings** and choose the project that already holds your billing export dataset. > **Warning** > > Choose the same project as your Detailed usage cost export. LevelFour looks for this export inside that project only. This export arrives as a **linked dataset**, created in your project through Analytics Hub and sitting alongside your billing export dataset. No new table appears in the dataset you already have. You usually end up with a `billing_export_cud` dataset next to `billing_export`. You do not report the dataset name to LevelFour. It looks for the export table inside your billing project. ### Grant LevelFour read access The connect commands do not cover this dataset, because it did not exist when you ran them. Grant access yourself. It takes two grants: one so LevelFour can find the dataset, one so it can read it. Use the address the connect script printed; see [Finding your service-account address](/connectors/gcp#finding-your-service-account-address) if you no longer have it. **Let LevelFour find the dataset.** From Cloud Shell: ```bash SA="PASTE_THE_ADDRESS_THE_SCRIPT_PRINTED" gcloud projects add-iam-policy-binding YOUR_EXPORT_PROJECT_ID \ --member="serviceAccount:$SA" --role="roles/bigquery.metadataViewer" ``` **Let LevelFour read the dataset.** Run `bq ls --datasets YOUR_EXPORT_PROJECT_ID` if you are not sure what Google named the linked dataset. In the Google Cloud console, open **BigQuery**, find the linked dataset, then **Sharing > Permissions > Add principal**. Paste the service-account address and assign **BigQuery Data Viewer**. Or from Cloud Shell, edit the dataset's access list in place: ```bash bq show --format=prettyjson YOUR_EXPORT_PROJECT_ID:billing_export_cud > cud-access.json # Stop here and edit cud-access.json: add {"role": "READER", "userByEmail": ""} to the "access" array bq update --source cud-access.json YOUR_EXPORT_PROJECT_ID:billing_export_cud ``` ### Wait for the first snapshot Google writes this export as a daily snapshot. Allow up to 48 hours after enabling it, plus up to a day for LevelFour's next read. ## Confirming it worked Open **Connect Providers > Google Cloud** in the dashboard. > **Success** > > The **Committed Use Discounts (BigQuery export)** panel moves to **Connected**. Your commitments then carry a term, start and end dates, and a status. The panel reaches **Connected** only when all of the following hold: * Every Google Cloud connection in your account is an organization connection. * Your Detailed usage cost export is connected and has delivered data. * The linked dataset exists in the same project as your billing export. * LevelFour can list datasets in that project and read the linked dataset. See the [export status reference](/connectors/gcp/billing-export#export-status-reference) for what each label means. ## Removing access Remove the LevelFour principal from the linked dataset's permissions in the BigQuery console, then drop the project-level role: ```bash SA="PASTE_THE_ADDRESS_THE_SCRIPT_PRINTED" gcloud projects remove-iam-policy-binding YOUR_EXPORT_PROJECT_ID \ --member="serviceAccount:$SA" --role="roles/bigquery.metadataViewer" ``` Disabling the export in the Cloud Billing console stops new snapshots. Your commitments keep their coverage and utilization figures, which come from the usage cost export. ## Troubleshooting **The panel stays at Not connected yet.** Most often the linked dataset landed in a different project than your billing export, or the grants in [Grant LevelFour read access](#grant-levelfour-read-access) were never applied. Confirm with `bq ls --datasets YOUR_EXPORT_PROJECT_ID` that the dataset is in the expected project. **You connected a single project and the panel stays at Not connected yet.** Commitment lifecycle data needs an organization connection. Reconnect at the organization level. **The panel shows Sync error.** A permission error means the grants in [Grant LevelFour read access](#grant-levelfour-read-access) are missing or have not propagated yet. Wait a few minutes and check again. **Commitments show coverage but no term or dates.** Expected before this export lands. Coverage comes from the usage cost export. ## Next * [Billing export](/connectors/gcp/billing-export) is the usage cost export that coverage itself comes from * [All connectors](/connectors) has the other providers you can connect --- # DigitalOcean Source: https://docs.levelfour.ai/connectors/digitalocean.md ## Prerequisites * A DigitalOcean team. Connect one team per token. * A team role that can see billing and resources, such as Owner. A token only carries the reads its creator's role allows. ## Connect ### Generate a token in DigitalOcean Sign in to the DigitalOcean control panel and switch to the team you want to connect. Open **API**, then **Tokens**, and click **Generate New Token**. Name it for LevelFour and choose an expiration. Under **Scopes**, choose **Read Only**. LevelFour can then read your billing and resources, including resource types DigitalOcean adds later, and cannot create, change, or delete anything. Read Only does not include database credentials or Kubernetes cluster credentials. For spend alone, **read** on `account` and `billing` is enough. Your costs, invoices and tags all arrive on that pair. The resource scopes below are what add the estate: which Droplets, volumes and snapshots you are paying for. `monitoring` adds how hard each one works, which is what turns an unused Droplet into a saving. Click **Generate Token** and copy it. It starts with `dop_v1_`, and DigitalOcean shows it only once. > **Note** > > If your security policy requires the narrowest token, choose **Custom Scopes** instead and tick **read** on `account`, `billing`, `droplet`, `block_storage`, `snapshot`, `database`, `kubernetes`, `load_balancer`, `reserved_ip`, `app`, `project`, `sizes`, and `monitoring`. Leave `monitoring` out and LevelFour can still read your bill, but it can never tell you a Droplet is idle. DigitalOcean does not let you add scopes to a custom token later, so a new resource type means a new token. > **Warning** > > Do not choose **Full Access**. It lets the token create, change, and delete resources. ### Add it to LevelFour Open **Connect Providers**, select **DigitalOcean**, and click **Add access token**. Paste the token, then click **Connect account**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. If the connection shows **Warning** with a note about missing scopes, the token could not list some resource types, or could not read utilization. LevelFour keeps syncing costs either way. See [Rotating the credential](#rotating-the-credential) to replace the token. ## Measuring utilization Telling you a Droplet is idle, or a size too big, needs more than the bill. LevelFour reads DigitalOcean's own monitoring for that, and two things have to be true before it can. **The token needs the `monitoring` scope.** Read Only includes it. A custom token that omits it leaves the connection in **Warning**. **The Droplet needs the DigitalOcean metrics agent.** Without the agent a Droplet reports its bandwidth and nothing else: no CPU, no memory, no disk, no load average. LevelFour will not call a Droplet idle on bandwidth alone, so it stays quiet about that Droplet rather than guessing at it. DigitalOcean installs the agent when you create a Droplet with monitoring enabled, and every Kubernetes worker node has it. To add it to a Droplet that is already running, run this on the Droplet: ```bash title="Install the DigitalOcean metrics agent" curl -sSL https://repos.insights.digitalocean.com/install.sh | sudo bash ``` [DigitalOcean's install guide](https://docs.digitalocean.com/products/monitoring/how-to/install-agent/) covers other install methods and how to confirm the agent is reporting. > **Note** > > A fortnight of readings has to accumulate before the first idle or right-size finding appears. A Droplet watched for two days is not evidence, so LevelFour waits rather than acting on a quiet weekend. > **Note** > > LevelFour never recommends deleting or resizing a Kubernetes worker node. The cluster owns those, and replaces any node you remove. ## Rotating or removing access ### Rotating the credential Generate a new Read Only token, paste it into **Connect Providers**, then delete the old token in DigitalOcean. Rotating does not repeat the first import. ### Removing access Disconnect DigitalOcean under **Connect Providers**, then delete the token under **API** in the DigitalOcean control panel. ## Troubleshooting | Symptom | What to do | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The token is refused | Check that it is Read Only (or has `account:read` and `billing:read`), that it has not expired, and that its creator can see billing. The token stops working if the person who created it leaves the team | | The connection shows **Warning** about missing scopes | A custom token lacks a resource scope. Generate a Read Only token and rotate it | | The connection shows **Warning** about utilization | The token cannot read `monitoring`. Generate a Read Only token and rotate it | | No Droplet is ever reported idle or oversized | Those Droplets have no metrics agent, the token lacks `monitoring`, or the first fortnight of readings is still building. See [Measuring utilization](#measuring-utilization) | | Databases are not listed | The token can read database credentials. Generate a Read Only token, or a custom token without `database:view_credentials` | | A daily figure differs from the invoice | Daily figures are DigitalOcean's nightly estimates. The reconciliation line added when the invoice arrives makes the month match the invoice | | Spend from another team is missing | Each team needs its own token and connection. LevelFour does not read organization-level billing across several teams yet | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Cloudflare Source: https://docs.levelfour.ai/connectors/cloudflare.md ## Prerequisites * A Cloudflare pay-as-you-go account. * Access to create an account-scoped API token with the Billing Read permission. ## Connect ### Create an API token in Cloudflare In the Cloudflare dashboard, create a custom API token with **Account > Billing > Read** for the account you want to connect. Copy the token and your 32-character Account ID from the dashboard URL. ### Add it to LevelFour Open **Connect Providers**, select **Cloudflare**, and paste the token and Account ID. Click **Add API token**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new API token in Cloudflare, paste it into **Connect Providers**, then revoke the old token. ### Removing access Disconnect Cloudflare under **Connect Providers**, then revoke the token from Cloudflare's dashboard. ## Troubleshooting | Symptom | What to do | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | The connection is refused | Enterprise contract accounts are not supported. Confirm the account is pay-as-you-go | | Reported spend is much lower than your invoice | Fixed plan subscription fees are not included. Only usage overage is covered | | One account in a multi-account setup shows nothing | Each account needs its own token and connection. A token scoped to one account does not cover another | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Azure Source: https://docs.levelfour.ai/connectors/azure.md Azure is not part of the self-service onboarding flow yet. To connect a Microsoft Azure tenant today, talk to your LevelFour contact. See [Connect Providers](/connectors) for what you can connect today. --- # Kubernetes Source: https://docs.levelfour.ai/connectors/kubernetes.md Connecting a Kubernetes cluster is not self-service yet. Ask your LevelFour contact to connect one. See [Connect Providers](/connectors) for what you can connect today. --- # OpenAI Source: https://docs.levelfour.ai/connectors/openai.md ## What each plan can show OpenAI publishes a bill for the API and per-person analytics for ChatGPT Enterprise and Edu. Codex reports its own usage to LevelFour on any plan, and so do your own apps through OpenTelemetry. Chat in ChatGPT is measured on Enterprise and Edu alone: on Business, Plus and Pro, OpenAI publishes nothing about it, and it does not run on your machine. | Your plan | What you pay | Connect | What you see | | ------------------------------------- | ------------------- | --------------------------------- | -------------------------------------------------------------------------- | | OpenAI API (platform.openai.com) | Per token | OpenAI Platform | Your bill by project, model, token type and API key | | OpenAI API, called from your own apps | Per token | App usage, beside OpenAI Platform | The same calls by app and model, valued at list price | | ChatGPT Enterprise or Edu | Seats, plus credits | ChatGPT Enterprise | What each person used of ChatGPT, Codex and Work, in dollars or in credits | | ChatGPT Business, Plus or Pro | Seats or a flat fee | Codex usage | Who uses Codex and ChatGPT Work, with which model, and how much. Not chat | Connect any mix, in any order. Billing data holds no activity, so add **Codex usage** beside a bill to see sessions, prompts and tools next to what you pay. A source can hold more than one account: a second OpenAI organization, a second ChatGPT workspace, or a settings block per team. Add another at any time from the same page. **Name** is optional free text, and LevelFour uses it only to tell accounts apart. A ChatGPT workspace billed in credits shows credits. LevelFour does not turn credits into dollars, because the price of a credit is set in your agreement. ## History and what is left out | Source | History on connect | First data | Not included | | ------------------ | ---------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | OpenAI Platform | 180 days, by day | About an hour | Tax | | ChatGPT Enterprise | 90 days, by day | About an hour | Seat fees, prompts and responses | | Codex usage | From the day you add the settings. Codex reports nothing earlier | A few minutes after you verify | Your bill, chat in ChatGPT, Codex and Work tasks that run in OpenAI's cloud, developers who have not added the settings, and [what LevelFour never reads](#what-levelfour-never-reads) | | App usage | From the day you set the variables | A few minutes after you verify | Calls a trace sampler dropped, and calls your app made to another vendor | ## Prerequisites **OpenAI Platform** * An organization on the OpenAI Platform. * Organization owner access, to create the Admin key. **ChatGPT Enterprise** * A ChatGPT Enterprise or Edu workspace. ChatGPT Business has no analytics API. * A workspace Owner or Admin, to create the workspace Admin key. **Codex usage** * Codex on each developer's machine: the CLI, the IDE extension or the ChatGPT app, on any plan. * For a whole organization: an admin who manages your developers' machines, through MDM or a managed configuration file. **App usage** * An app that calls the OpenAI API through an official SDK. * The OpenTelemetry SDK of the app's language, or an OpenTelemetry Collector the app already exports to. ## Connect Open **Connect Providers** and select **OpenAI**. The first time an admin opens it, **Connect OpenAI** starts by itself; after that, click **Add account**. Pick each plan your company pays for and click **Continue**: | Plan you pick | Steps it adds | | ---------------------------------------- | ------------------------------------------------------------------------- | | OpenAI API | OpenAI Platform: an Admin key, with Codex and Your apps as optional steps | | ChatGPT Enterprise or Edu | ChatGPT Enterprise: a workspace Admin key, with Codex as an optional step | | ChatGPT Business, or ChatGPT Plus or Pro | Codex: a settings block | Two plans with the same source share one step. You can skip a step or hand it to a colleague, and a review at the end lists what you connected and what is still open. Run it again at any time: it only adds accounts. Only admins connect, rotate and disconnect; a member or viewer sees each account and its status. **OpenAI Platform** ### Create a read-only Admin key Sign in to the OpenAI Platform as an organization owner. Open [**Admin keys**](https://platform.openai.com/settings/organization/admin-keys), click **Create new admin key**, and name it for LevelFour. Under **Permissions**, give the key **Read** access and no **Write**, and set an expiration before you click **Create admin key**. If you change the permission later, OpenAI may not apply it. Copy the key (it starts with `sk-admin-`). OpenAI shows it once. ### Add it to LevelFour In **Connect OpenAI**, pick **OpenAI API**. On the **OpenAI Platform** step, click **Add Admin API key**, paste the key and click **Connect account**. LevelFour reads the costs report grouped by project, API key and line item, and the completions usage report for the tokens behind each charge. A charge that no API key made, such as a fine-tuning job, shows as **(no key)**. The account is the OpenAI organization the key belongs to, so a second key of an organization you already connected adds no account. **ChatGPT Enterprise** ### Create a workspace Admin key Sign in to the [ChatGPT Admin Console](https://chatgpt.com/admin) as a workspace Owner or Admin. Go to **Credentials > Admin keys** and create a key for LevelFour with **Read** on **Enterprise analytics API** and **Codex analytics API**. Copy it (it starts with `sk-admin-`). It is shown once. Copy the **Workspace ID** from the workspace details page too. ### Add it to LevelFour In **Connect OpenAI**, pick **ChatGPT Enterprise or Edu**. On the **ChatGPT Enterprise** step, click **Add workspace Admin key**, paste the key and the Workspace ID, and click **Connect account**. LevelFour reads what each person used per day from the Enterprise analytics API, and the tokens of each Codex model from the Codex analytics API. A key with only one of the two permissions connects, and the account says which one it reads. > **Warning** > > Seat fees are not in either API, so your ChatGPT Enterprise total is lower than your invoice. A workspace billed in credits shows credits and no dollars. **Codex usage** ### Generate the settings In **Connect OpenAI**, pick the plan that uses Codex. On the **Codex** step, click **Generate settings**. LevelFour shows a settings block that holds a new ingest key. ```toml title="~/.codex/config.toml" [otel] log_user_prompt = false exporter = { otlp-http = { endpoint = "https://api.levelfour.ai/api/v1/otlp/v1/logs", protocol = "json", headers = { "Authorization" = "Bearer l4i_..." } } } ``` > **Warning** > > Copy the block before you leave the page. LevelFour keeps only a hash of the key and shows it this one time. ### Add it to Codex * **One developer:** add the block at the end of `~/.codex/config.toml`, below every other setting. TOML puts each key under the table above it, so a block pasted at the top would capture the settings after it. Then quit Codex and the ChatGPT app and open them again: Codex reads the file when it starts. Codex reads it in the CLI, the IDE extension and the ChatGPT app. * **Every developer in your organization:** encode the block in base64 with no line wrapping, and push it to Macs through MDM (preference domain `com.openai.codex`, key `config_toml_base64`). Without MDM, write the block to `/etc/codex/managed_config.toml` on macOS and Linux, or `~/.codex/managed_config.toml` on Windows. Codex applies it the next time it starts, so ask developers to quit and reopen Codex and the ChatGPT app. A developer can still change a managed default during a session. LevelFour does not enforce collection. > **Warning** > > A configuration file holds one `[otel]` table, so Codex on a machine reports to one key. If the file already has an `[otel]` table, replace it. A machine that also runs Codex against DeepSeek sends those sessions here too, and LevelFour leaves out each request for a model OpenAI does not serve. The ChatGPT app runs Work tasks through the same Codex, so the block measures them too. They show as **ChatGPT Work**, apart from Codex. Chat in the app is not measured, and neither is a task that runs in OpenAI's cloud. ### Verify Run one prompt in Codex, then click **Verify connection** in the step. If your rollout takes days, close the dialog: the block waits for its first event on the OpenAI page, and you verify it there later. **App usage** ### Instrument the OpenAI SDK Add the OpenTelemetry instrumentation of the OpenAI SDK to your app. In Python: ```bash pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http opentelemetry-instrumentation-genai-openai ``` Run the app under `opentelemetry-instrument`, for example `opentelemetry-instrument python app.py`, and set `OTEL_SERVICE_NAME` to the name LevelFour should show for the app. Other languages have their own OpenAI instrumentation, and OpenLLMetry covers several. `opentelemetry-instrumentation-genai-openai` traces chat completions, the Responses API and embeddings, and reports cached input, which LevelFour prices at the cache rate. It replaces `opentelemetry-instrumentation-openai-v2`, which traces no Responses API call and reports no cached input, so an app on it reads high. > **Warning** > > A streamed chat completion carries token counts only when the app asks for them with `stream_options={"include_usage": True}`. Without it, the call reaches LevelFour with no tokens and is left out. A streamed Responses API call always carries them. ### Generate the variables In **Connect OpenAI**, pick **OpenAI API**, then the optional **Your apps** step, and click **Generate settings**. LevelFour shows the environment variables with a new ingest key, once. ```bash title=".env" OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://api.levelfour.ai/api/v1/otlp/v1/traces OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Bearer%20l4i_... OTEL_TRACES_SAMPLER=always_on OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false ``` ### Set them where the app runs Add the variables to the app's environment: its container, its deployment or its `.env` file. An app that already exports traces elsewhere keeps doing so through an OpenTelemetry Collector, with a second exporter for LevelFour. See [Sending through a Collector](#sending-through-an-opentelemetry-collector). ### Verify Make one model call from the app, then click **Verify connection** in the step. LevelFour reads only the spans of model calls. It reads a span of a chat, completion or embedding call and skips agent and workflow spans, which repeat the tokens of the calls under them. A call the sampler dropped never reaches LevelFour, which is why the variables keep every trace. ## What LevelFour keeps from Codex Codex reports each thing a developer does as an event. LevelFour adds it to a daily total and stores the total, never one row per event. | Kept | As a daily total of | | --------------- | ------------------------------------------------------------------------------------------------------------------------ | | Usage | Requests, tokens by type and usage value, per person and model | | Sessions | Conversations started | | Prompts | How many, and their length in characters | | Tools | Calls per tool, how many succeeded, and how long they took. A tool an MCP server serves counts as one row, **MCP tools** | | Tool approvals | Calls approved or denied, and what decided: configuration or a person | | Failed requests | Model requests that did not go through, by status code where Codex sends one | | Speed | Time to first token by model, and requests per reasoning effort | LevelFour keeps usage, sessions, prompt counts and failed requests per person. Every other total covers all developers together and carries no name. Each person has one profile: the Codex version and terminal they last reported, and the first and last day they reported. Codex reports no cost, so LevelFour values its tokens at OpenAI's API list price. ### What LevelFour never reads Codex sends a tool's arguments and output with every tool result. LevelFour discards them unread. It never reads or stores: * the text of a prompt or a response * a tool's arguments or output * a command * file content The settings block keeps `log_user_prompt` off, so Codex sends no prompt text. ### Who a request belongs to A developer signed in to Codex with ChatGPT shows under their email, in the CLI and in the ChatGPT app. Codex signed in with an API key names no person, so LevelFour shows the account, or the machine when there is none. A developer who runs the CLI on an API key can name themselves by setting `OTEL_RESOURCE_ATTRIBUTES=levelfour.person=` in the shell Codex runs in. The ChatGPT app does not read your shell's variables, which it does not need: it is always signed in with ChatGPT. ### Sending through an OpenTelemetry Collector You can put an OpenTelemetry Collector between Codex or your apps and LevelFour. Have it export OTLP over HTTP, as JSON or protobuf, to the route of each signal: `/api/v1/otlp/v1/logs` for Codex and `/api/v1/otlp/v1/traces` for apps. LevelFour refuses gRPC, a batch over 2 MB, and a batch that holds more than 1,024 model calls. ```yaml title="otel-collector.yaml" exporters: otlphttp/levelfour: traces_endpoint: https://api.levelfour.ai/api/v1/otlp/v1/traces headers: Authorization: Bearer l4i_... processors: filter/genai: error_mode: ignore traces: span: - >- attributes["gen_ai.usage.input_tokens"] == nil and attributes["gen_ai.usage.prompt_tokens"] == nil and attributes["gen_ai.usage.output_tokens"] == nil and attributes["gen_ai.usage.completion_tokens"] == nil service: pipelines: traces/levelfour: receivers: [otlp] processors: [filter/genai] exporters: [otlphttp/levelfour] ``` The filter keeps only the spans that carry token counts, so the rest of your traces stay with your own backend. It reads the counts rather than `gen_ai.operation.name`, which older instrumentations leave out. An app that calls both OpenAI and DeepSeek sends through a Collector with one exporter per vendor, each with its own key: LevelFour leaves out a call that a key of the other vendor sent. ## What you see OpenAI spend appears under [**Costs and Usage**](/cost-and-usage) and on its own **OpenAI** board under [**Boards**](/boards), which appears once you connect a source. | Group by | OpenAI Platform | ChatGPT Enterprise | Codex and app usage | | ------------------ | ------------------------- | ----------------------------------- | ---------------------------- | | Model | The model | The product: ChatGPT, Codex or Work | The model | | Project or tool | Project | Client | Codex, ChatGPT Work or Apps | | Token type | Input, Cache read, Output | Not reported | On the board's tokens card | | Key, person or app | API key, or **(no key)** | Person | Person, or app for app usage | ### Billed and usage value LevelFour keeps two figures apart, and a switch on the chart and the table chooses between them. * **Billed** is what OpenAI charges you through the API, and what a ChatGPT workspace billed in dollars reports. * **Usage value** is Codex and app usage priced at OpenAI's API list price. It estimates what that usage would cost per token. LevelFour never adds it to a billed total. ### The OpenAI board The board sorts its cards into groups under headings. A card appears once it has data, and a heading appears once its group has a card. **Bill** reads OpenAI Platform and ChatGPT Enterprise. Every other group reads what Codex and your apps report, with the current day included. | Group | Cards | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Bill | Spend summary, cost by model, by project or tool, by token type, and by key, person or app, the cost chart and table, active anomalies | | Usage | Usage value, active people, requests and tokens; usage value and tokens per day; usage by person and by model; Codex versus apps; usage bands and concentration; cache efficiency; inactive people | | Activity | Sessions per day, prompts, new and returning people | | How Codex is used | Tools used most, tool success rate, tool approvals | | Speed and reliability | Response time by model, rate limits and API errors, slowest tools, effort and fast mode | | Fleet | Codex versions, terminals and IDEs | Cards that compare people need two or more people who reported usage. ## Confirming it worked Each account you connect moves to **Connected** on the OpenAI page. OpenAI Platform and ChatGPT Enterprise data appears in about an hour. Codex and app usage turn **Connected** after the first prompt or model call and **Verify connection**, and their figures appear on the board within minutes. ## Rotating or removing access ### Rotating a credential Create a new key in OpenAI. On the OpenAI page under **Connect Providers**, click **Rotate credential** on the account, paste the new key, then revoke the old one where you created it. You cannot rotate an ingest key. Disconnect the Codex or app usage source, keeping its data, generate the settings again, and replace the block or the variables wherever you put them. ### Removing access Click **Disconnect** on the account under **Connect Providers**, then revoke the key: an Admin key on the OpenAI Platform's **Admin keys** page, a workspace Admin key under **Credentials > Admin keys** in the ChatGPT Admin Console. Disconnecting a usage source stops its ingest key working at once. Remove the block or the variables so Codex or your app stops sending. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | LevelFour rejects the OpenAI Platform key | OpenAI's organization reports refuse a project API key. Create an Admin key under **Admin keys**: it starts with `sk-admin-` | | Key creation fails or the Admin keys page is not visible | You need organization owner access on the OpenAI Platform account | | LevelFour rejects the ChatGPT Enterprise key | Check the key has **Read** on **Enterprise analytics API** or **Codex analytics API**, and that you copied the Workspace ID of the workspace you created it in | | ChatGPT Enterprise shows credits and no dollars | Expected for a workspace billed in credits | | A charge shows **(no key)** | No API key made it, as with fine-tuning and some batch jobs | | **Verify connection** says no usage has arrived | Check the block sits at the end of the `config.toml` Codex reads, quit and reopen Codex, then run one prompt. For an app, check the variables are in its environment, then make one model call. A chat in ChatGPT sends nothing | | The account shows a warning that a model has no list price | A request used a model LevelFour cannot price yet. Its tokens count, at no cost, until LevelFour adds the price | | Codex usage stopped after you set up Codex for DeepSeek | The machine's `[otel]` table now names the DeepSeek key. A machine reports Codex to one key | | A Codex developer shows as an account id or a machine name | They use Codex with an API key. Have them set `levelfour.person`, as in [Who a request belongs to](#who-a-request-belongs-to) | | App calls are missing | Check you started the app under the instrumentation, that no sampler drops traces, and that the app calls OpenAI itself. A streamed chat completion needs `stream_options={"include_usage": True}` | | Azure OpenAI spend is missing | This page covers OpenAI itself. Azure bills Azure OpenAI | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [Anthropic](/connectors/anthropic) and [DeepSeek](/connectors/deepseek) connect the same way --- # Anthropic Source: https://docs.levelfour.ai/connectors/anthropic.md ## What each plan can show Anthropic publishes billing data for the Claude API and for Claude Enterprise only. For every other plan there is no bill to read, so LevelFour shows the usage that Claude Code and Cowork report themselves: who used which model, how much, what that usage is worth at API list price, and how developers work with Claude Code. | Your plan | What you pay | Connect | What you see | | -------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Claude API, in a Claude Console organization | Per token | Claude API | Your bill by model, workspace, token type and API key | | Claude API on an individual Console account | Per token | Claude Code and Cowork usage, picked as **Claude Pro or Max** | What Claude Code uses on your API key, by model. Anthropic offers individual accounts no Admin API, so LevelFour cannot connect the account or read its bill | | Claude Enterprise, usage-based | Per token | Claude Enterprise | Your bill by model, product and person, at list price and after your discount | | Claude Enterprise, seat-based | Seats, plus usage credits | Claude Enterprise and Claude Code and Cowork usage | Your usage credits by person, and who uses Claude, with which model, and how much | | Team | Seats, plus usage credits | Claude Code and Cowork usage | Who uses Claude Code and Cowork, with which model, and how much. Not chat on claude.ai | | Pro and Max | A monthly subscription, plus usage credits | Claude Code and Cowork usage | What you use in Claude Code and Cowork, by model. Not chat on claude.ai | Connect any mix of the three sources, in any order. Billing data holds no activity, so add **Claude Code and Cowork usage** beside a Claude API or Claude Enterprise connection to see sessions, tools and output next to what you pay. A source can hold more than one account: a second Claude API organization, a second Claude Enterprise organization, or a settings block per team. Add another at any time from the same page. **Name** is optional free text, and LevelFour uses it only to tell accounts apart in the connected accounts list. On the Claude API, the account is the Claude Console organization. A second Admin key of an organization you already connected adds no account: it ends as "already connected". LevelFour does not ask you to type what a subscription costs. A subscription is the same every month whatever is used, so it has nothing to show day by day. Anthropic bills usage credits beyond a subscription at API rates, and publishes no report LevelFour can read for them. > **Note** > > LevelFour does not collect chat on claude.ai for Team, Pro and Max plans. Anthropic publishes no usage data for it, and it does not run on your machine. Only a Claude Enterprise connection shows chat. ## History and what is left out | Source | History on connect | First data | Not included | | ---------------------------- | ------------------------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | Claude API | 180 days, by day | About an hour | Priority Tier and tax | | Claude Enterprise | 180 days, by day, no earlier than January 1, 2026 | About an hour | Seat fees. On a seat-based plan Anthropic reports usage credits only | | Claude Code and Cowork usage | From the day the settings are added. Claude Code reports nothing earlier | A few minutes after you verify | Your bill, chat on claude.ai, developers who have not added the settings, and [what LevelFour never reads](#what-levelfour-never-reads) | ## Prerequisites **Claude API** * An organization on Claude Console. Not available on Claude Platform on AWS. * Organization admin access, to create the Admin API key. * An individual Console account has no Admin keys and no cost report, because Anthropic offers individual accounts no Admin API. LevelFour cannot connect one. To see what Claude Code uses on it, pick **Claude Pro or Max** and connect [Claude Code and Cowork usage](#claude-code-usage). **Claude Enterprise** * A Claude Enterprise organization, billed by usage or by seat. * The Primary Owner, to create the Analytics key. Cost data starts at January 1, 2026, so history before that date is not available. **Claude Code and Cowork usage** * Claude Code on each developer's machine, on any plan, personal ones included. * For a whole organization on Team or Enterprise: an owner, to paste the settings once for everyone. ## Connect Open **Connect Providers** and select **Anthropic**. The first time an admin opens it, **Connect Claude** starts by itself; after that, click **Add account**. Pick each plan your company pays for and click **Continue**: | Plan you pick | Step it adds | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Claude API | Claude API: an Admin API key. Claude Code: a settings block, optional | | Claude Enterprise | Claude Enterprise: an Analytics API key. Claude Code and Cowork: a settings block, optional | | Claude Team | Claude Code and Cowork: a settings block | | Claude Pro or Max, or Claude Code on your own API key | Claude Code: a settings block | Two plans with the same source share one step. You can skip a step or hand it to a colleague, and a review at the end lists what is connected and what is still open. Run it again at any time to add a second organization or another team: it only adds accounts. Only admins connect, rotate and disconnect; a member or viewer sees each account and its status. **Claude API** ### Create an Admin API key Sign in to [Claude Console](https://platform.claude.com) as an org admin. Go to [**Settings > Admin keys**](https://platform.claude.com/settings/admin-keys), create a key, and set an expiry. An individual Console account has no such page, and LevelFour cannot connect one. Copy the key (it starts with `sk-ant-admin01-`). Admin keys have no scopes, so this key can also make changes in your organization. LevelFour only reads with it. Create one for LevelFour alone, so you can revoke it without touching anything else. ### Add it to LevelFour In **Connect Claude**, pick **Claude API**. On the **Claude API** step, paste the key and click **Connect account**. LevelFour reads the cost report for what Anthropic billed you and the usage report for who used it. Anthropic publishes no cost per API key, so LevelFour shares each charge between the keys that used that model in that workspace on that day, in proportion to their tokens. The shares add up to the charge. **Claude Enterprise** ### Create an Analytics key Sign in to [claude.ai](https://claude.ai) as the organization's Primary Owner. Go to [**Organization settings > API**](https://claude.ai/admin-settings/api-access), turn on public API access, and create a key with the `read:analytics` scope. Copy the key (it starts with `sk-ant-api01-`) and your Organization ID. ### Add it to LevelFour In **Connect Claude**, pick **Claude Enterprise**. On the **Claude Enterprise** step, paste the key and the Organization ID, and click **Connect account**. > **Warning** > > On a seat-based Enterprise plan, Anthropic reports usage credits only, not your seat fee, so your Claude Enterprise total will be lower than your invoice. Add [Claude Code and Cowork usage](#claude-code-usage) in the next step to see how your people use their seats. Spend that belongs to no seat user, such as a service account, appears as **(not a seat user)**, so the rows still add up to your organization's total. **Claude Code and Cowork usage** ### Generate the settings In **Connect Claude**, pick **Claude Team** or **Claude Pro or Max**, or add the optional step to **Claude Enterprise** or **Claude API**. Pick **Claude Pro or Max** too for Claude Code that runs on your own API key. On the **Claude Code and Cowork** step, called **Claude Code** when you picked no Team or Enterprise plan, click **Generate settings**. LevelFour shows a settings block that holds a new ingest key. ```json title="~/.claude/settings.json" { "env": { "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_LOGS_EXPORTER": "otlp", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/json", "OTEL_EXPORTER_OTLP_ENDPOINT": "https://api.levelfour.ai/api/v1/otlp", "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer l4i_...", "OTEL_LOGS_EXPORT_INTERVAL": "60000", "OTEL_METRIC_EXPORT_INTERVAL": "60000" } } ``` > **Warning** > > Copy the block before you leave the page. LevelFour keeps only a hash of the key and shows it this one time. ### Add them to Claude Code * **One developer, on any plan or on a Claude Console API key:** add the eight entries of the block to the `env` object of `~/.claude/settings.json`, next to what is already there. If the file has no `env`, add the whole `env` object. * **A whole organization on Team or Enterprise:** an owner pastes the block under [**Organization settings > Claude Code > Managed settings**](https://claude.ai/admin-settings/claude-code) in claude.ai. Each developer approves the endpoint the next time Claude Code starts, and Claude Code exits for a developer who declines. A developer signed in with a Claude Console API key never gets managed settings, so they add the block themselves. * **Cowork on Team or Enterprise:** an owner opens **Organization settings > Cowork** in [claude.ai](https://claude.ai) and fills in three fields: **OTLP endpoint** with the value of `OTEL_EXPORTER_OTLP_ENDPOINT` in the block, **OTLP protocol** with `http/json`, and **OTLP headers** with the value of `OTEL_EXPORTER_OTLP_HEADERS`. A block with six entries comes from before LevelFour collected activity. Add the two it lacks wherever you pasted it: `"OTEL_METRICS_EXPORTER": "otlp"` and `"OTEL_METRIC_EXPORT_INTERVAL": "60000"`. They turn on Claude Code's metrics, which carry sessions, active time, edits, lines of code, commits and pull requests. The key in that block stays valid. ### Verify Run one prompt in Claude Code, then click **Verify connection** in the step. If your rollout takes days, close the dialog: the block waits for its first event on the Anthropic page, and you verify it there later. > **Note** > > The ingest key can write these reports and nothing else. [What LevelFour keeps from Claude Code and Cowork](#what-levelfour-keeps-from-claude-code-and-cowork) lists what it stores and what it never reads. A developer who has not added the settings, or who removes them, does not appear. LevelFour does not enforce collection. ## What LevelFour keeps from Claude Code and Cowork Claude Code and Cowork report each thing a developer does as an event or a counter. LevelFour adds it to a daily total and stores the total, never one row per event. | Kept | As a daily total of | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | Usage | Requests, tokens by type and usage value, per person and model, and the share from the main loop, subagents, the SDK and background work | | Sessions and active time | Sessions started (fresh, resumed or continued) and time of active use | | Prompts | How many, and their length in characters | | Tools | Calls per tool, how many succeeded, the error type of those that failed, and how long they took | | Tool approvals | Calls accepted or rejected, and what decided: configuration, a hook or a person | | Edits | Edits accepted or rejected, by programming language | | MCP connections | Connections and failures by scope and transport, and the time to connect | | Plugins and skills | Loads and uses by name, as Claude Code sends the name | | Permission modes | Each change: the mode switched to and what triggered it | | API errors | Failed requests by status code | | Speed | Response time and time to first token by model, and requests per effort level and in fast mode | | Output | Lines of code added and removed, commits and pull requests | LevelFour keeps usage, sessions, active time, prompt counts, API errors, lines of code, commits and pull requests per person. Every other total covers all developers together and carries no name. Each person has one profile: the Claude Code version, terminal and operating system they last reported, their Claude organization ID, whether managed settings applied, and the first and last day they reported. ### What LevelFour never reads LevelFour never reads or stores: * the text of a prompt or a response * a tool's input or parameters * a command * file content * an error message Claude Code redacts prompt and response text by default. If you turn that text on in Claude Code, LevelFour does not read it. On a Team or Enterprise plan, Cowork sends prompt text, response text and tool input with its events by default, along with each person's email. LevelFour keeps the email to tell people apart and never stores the text. Anthropic's [Cowork monitoring guide](https://claude.com/docs/cowork/monitoring) says how to stop Cowork sending the text. ### Names Claude Code redacts by default | In Claude Code | Arrives in LevelFour as | | -------------------- | --------------------------------------------------- | | A third-party plugin | `third-party` | | A custom skill | `custom_skill` | | An MCP tool | One row, **MCP tools**, with no server or tool name | LevelFour shows them as they arrive. An official plugin, and a skill it ships, arrives under its real name. > **Warning** > > Leave `OTEL_LOG_TOOL_DETAILS` off. It makes Claude Code send the real names, and commands and tool input along with them. LevelFour does not ask for it. ### Repository names and team tags Claude Code sends no repository name and no team until you turn them on. The block LevelFour generates holds neither, so add them yourself to the `env` object that holds the block. | To see | Add to `env` | Card that appears | | -------------------- | -------------------------------------------------------------------------------- | ----------------------- | | Usage per repository | `"OTEL_METRICS_INCLUDE_REPOSITORY": "true"` | **Usage by repository** | | Usage per team | `"OTEL_RESOURCE_ATTRIBUTES": "team="`, with one name per group of machines | **Usage by team** | Each card appears once data arrives with a repository or a team on it. LevelFour stores a repository as `owner/name`. ### Sending through an OpenTelemetry Collector You can put an OpenTelemetry Collector between Claude Code and LevelFour. Have it export OTLP over HTTP, as JSON (`http/json`) or protobuf (`http/protobuf`), to the endpoint and with the header from the block. LevelFour refuses gRPC, a batch over 2 MB, and a batch that holds more than 1,024 usage events or more than 1,024 other events or data points. | Data | Through a Collector | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Usage | Kept for every request, whatever the batch holds | | Activity | Kept for the first 50 people a batch names. LevelFour leaves the rest out and says so in its answer to the Collector | | A retried batch | Counted once when the Collector sends the same batch again, as an exporter does on its own retry. A Collector that re-batches before it retries sends a different batch, and its activity counts twice. Usage counts once either way, because each request carries its own ID | ## What you see Anthropic spend appears under [**Costs and Usage**](/cost-and-usage) and on its own **Anthropic** board under [**Boards**](/boards), which appears once a source is connected. | Group by | Claude API | Claude Enterprise | Claude Code and Cowork usage | | -------------------- | ---------------------------------------- | ------------------------------------ | ---------------------------- | | Model | The model, or Web search, Code execution | The model | The model | | Workspace or product | Workspace | Chat, Claude Code, Cowork and others | Claude Code or Cowork | | Token type | Input, Output, Cache read, Cache write | Not reported | On the board's tokens card | | Key or person | API key, or the person signed in | Person | Person | ### Billed and usage value LevelFour keeps two figures apart, and a switch on the chart and the table chooses between them. * **Billed** is what Anthropic charges you through the Claude API and Claude Enterprise. * **Usage value** is Claude Code and Cowork usage priced at API list price. It estimates what that usage would cost per token, and it shows who uses Claude and how much. LevelFour never adds it to a billed total. On a Team, Pro or Max plan there is no billed spend in LevelFour, and the usage value shows how the subscription is being used. ### The Anthropic board The board sorts its cards into groups, each under a heading. A card appears once it has data, and a heading appears once its group has a card. **Bill** reads the Claude API and Claude Enterprise. Every other group reads what Claude Code and Cowork report, as it arrives and with the current day included. Under **Costs and Usage**, the usage value is as of the last daily sync. Some cards need two or more people who have reported usage: with one person they have nothing to compare. The **Needs** column names them. The top-three lists of people inside other cards follow the same rule. **Bill** | Card | Shows | Needs | | -------------------------------- | ----------------------------------------------------------------------- | ----------------- | | Spend summary | Billed spend month to date, last month, and the forecast for this month | | | Cost by model | Billed spend per model | | | Cost by workspace or product | Billed spend per Claude Console workspace or Claude Enterprise product | | | Cost by token type | Billed spend on input, output, cache read and cache write | Claude API | | Cost by key or person | Billed spend per API key or person | | | List price versus after discount | Each product at list price, beside the amount after your discount | Claude Enterprise | | Cost chart and table | Billed spend over time, and the same figures as rows | | | Active anomalies | Unusual jumps in billed spend | | **Usage** | Card | Shows | Needs | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------ | | Usage value, active developers, requests and tokens | The totals for the period | | | Usage value per day | Each day's usage value | | | Tokens by type | Input, output, cache read and cache write tokens per day | | | Active developers per day | People with at least one request that day | | | Usage value by person | People ranked, with each one's share of the total | | | Usage value by model | Models ranked, with each one's share of the total | | | Claude Code versus Cowork | Usage value split by product | Cowork usage | | Usage bands | How many developers fall in each band of usage value | Two or more people | | Concentration | The usage value of the developers who used the most, about a tenth of them, against everyone else | Two or more people | | Cache efficiency | Cache reads as a share of input tokens, per model and overall | | | Inactive developers | People who reported before and have sent nothing for 14 days, and for 30 | Two or more people | **Activity** | Card | Shows | Needs | | ---------------------------- | --------------------------------------------------------------- | ------------------ | | Sessions per day | Sessions started each day: fresh, resumed or continued | | | Active time | Time of active use per day, and the three most active people | | | Prompts | Prompts per developer and per session, and their average length | | | New and returning developers | Who started in the period, and who came back after time away | Two or more people | **How Claude is used** | Card | Shows | | ----------------- | -------------------------------------------------------------------------- | | Tools used most | Bash, Edit, Read, MCP tools and the rest, by number of calls | | Tool success rate | The share of calls that succeeded, and the error types behind the failures | | MCP connections | Connections by scope and transport, failures, and time to connect | | Plugins | Plugins loaded, by name and version | | Skills | Skills used, and whether a person or Claude started them | | Subagents and SDK | Usage value from the main loop, subagents, the SDK and background work | **Trust and control** | Card | Shows | Needs | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | Edits accepted versus rejected | Edits accepted and rejected, by programming language | | | Tool approvals | Tool calls accepted and rejected, and what decided: configuration, a hook or a person | | | Permission mode changes | How often developers switch mode per day, such as to auto-accept or plan | Two or more people | | Managed settings coverage | Developers who run with organization-managed settings, and those who run without | Two or more people | | Company versus other organizations | People and usage value in the Claude organization most of your developers report from, against every other organization | Two or more Claude organizations | **Speed and reliability** | Card | Shows | | -------------------------- | ----------------------------------------------------------------------------------------------------- | | Response time by model | Total time and time to first token: the median and the 90th percentile | | Rate limits and API errors | Failed requests per day by status code, rate limits among them, and the three people who hit the most | | Slowest tools | Average duration per tool | | Effort and fast mode | The share of requests at each effort level and in fast mode | **Output** | Card | Shows | | ------------------------- | ------------------------------------------------------------------------------------------------- | | Lines of code | Lines added and removed through Claude Code per day, and the three people with the most | | Commits and pull requests | Commits and pull requests created through Claude Code per day, and the three people with the most | **Fleet** | Card | Shows | Needs | | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------- | | Claude Code versions | Versions in use, and who is behind the newest of them | | | Terminals and IDEs | Where Claude Code runs: VS Code, iTerm, Cursor and the rest | | | Operating systems | macOS, Linux and Windows | Two or more people | | Usage by repository | Usage value per repository | [Repository names](#repository-names-and-team-tags) turned on | | Usage by team | People and usage value per team | A [team tag](#repository-names-and-team-tags) | ### Limits of the figures * **One profile per person.** Someone who runs two installs, such as a terminal and an editor extension on different versions, shows under whichever reported last. * **Inactive developers ignores the period.** The card counts from today, among people seen in the last 400 days. * **Average prompt length can read low.** Claude Code 2.1.288 and older send no prompt length, and LevelFour counts those prompts as empty. * **Response times are estimates.** LevelFour sorts each request's time into a range, then works out the median and the 90th percentile from the count in each range. ## Confirming it worked Each account you connect moves to **Connected** on the Anthropic page. Claude API and Claude Enterprise data appears in about an hour. Claude Code and Cowork usage turns **Connected** after the first prompt and **Verify connection**, and its figures appear on the board within minutes. ## Rotating or removing access ### Rotating a credential Create a new key in Claude Console or claude.ai. On the Anthropic page under **Connect Providers**, click **Rotate credential** on the account, paste the new key, then revoke the old key at its source. You cannot rotate an ingest key. Disconnect the Claude Code and Cowork usage source, keeping its data, generate the settings again, and replace the block wherever you pasted it. ### Removing access Click **Disconnect** on the account under **Connect Providers**, then revoke the key. An Admin API key is revoked from Claude Console's **Settings > Admin keys**; an Analytics key from claude.ai's **Organization settings > API**. Disconnecting the Claude Code and Cowork usage source stops its ingest key working at once. Remove the block from your settings so Claude Code stops sending. ## Troubleshooting | Symptom | What to do | | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | LevelFour rejects the key you pasted | A workspace-scoped API key is refused, whatever else it can do. The reporting endpoints take an Admin key (`sk-ant-admin01-`), an unscoped personal or service account key, or an OAuth token with the `org:admin` scope. On an individual Console account every key is refused, because Anthropic offers individual accounts no Admin API | | Claude Console has no **Admin keys** page, or the link opens "Page not found" | Your Console account is an individual account, which has no Admin keys and no cost report, so LevelFour cannot connect it. To see what Claude Code uses on it, pick **Claude Pro or Max** and connect [Claude Code and Cowork usage](#claude-code-usage) | | "Organization admin access required" when creating an Admin key | You need the admin role on the Claude Console organization, not member access | | The Claude Enterprise key cannot be created | Only the Primary Owner can create an Analytics key with `read:analytics`. Ask them, or check that public API access is turned on | | Claude Enterprise shows no data before January 2026 | Expected. Enterprise Analytics has no data before that date, regardless of when your organization started using Claude | | The Claude Enterprise key is refused as belonging to another organization | The Organization ID does not match the key. Copy both from the same page in claude.ai | | A Claude API charge shows **(no key)** | No API key or signed-in person used that model in that workspace on that day in the usage report. Code execution and Priority Tier are reported this way | | **Verify connection** says no usage has arrived | Check the block is in the settings Claude Code reads, restart Claude Code, and run one prompt. On managed settings, each developer has to approve the endpoint once | | A developer is missing from the usage value | They have not added the settings, or use Claude only through chat on claude.ai | | Sessions, active time, edits, lines of code and commits are missing while other cards fill | The block in use has six entries. Add the two metrics entries under [Claude Code and Cowork usage](#claude-code-usage) | | A plugin shows as `third-party`, a skill as `custom_skill`, or every MCP tool as one row | Expected. [Claude Code redacts these names by default](#names-claude-code-redacts-by-default) | | **Usage by repository** or **Usage by team** is not on the board | Claude Code sends neither until you [turn it on](#repository-names-and-team-tags) | | The settings block shows a placeholder where the key was | The key is shown once. Disconnect the source, keeping its data, and generate the settings again | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [OpenAI](/connectors/openai) and [DeepSeek](/connectors/deepseek) connect the same way --- # DeepSeek Source: https://docs.levelfour.ai/connectors/deepseek.md ## What you can connect DeepSeek publishes no usage or cost API. Its bill is a file you export from its platform, and the tools your team runs on DeepSeek report their own usage to LevelFour. | What you use | Connect | What you see | | ------------------------------- | -------------------- | ----------------------------------------------------------- | | The DeepSeek API, any use | Monthly usage export | Your bill by day, model, API key and token type | | Claude Code pointed at DeepSeek | Claude Code usage | Who uses which model, how much, and how Claude Code is used | | Codex pointed at DeepSeek | Codex usage | Who uses which model, how much, and how Codex is used | | Your own apps calling DeepSeek | App usage | Each model call by app and model | Connect any mix, in any order. The export is your bill. The tools add who used it and how. LevelFour values what the tools report at DeepSeek's list price for the hour each request was made. DeepSeek charges twice the off-peak rate from 01:00 to 04:00 and from 06:00 to 10:00 UTC, Monday to Friday. It bills Chinese public holidays off-peak, and LevelFour prices those days as weekdays, so a holiday reads a little high. ## History and what is left out | Source | History | First data | Not included | | -------------------------------- | --------------------------------- | ------------------------------------ | -------------------------------------------------------- | | Monthly usage export | Every month you upload | Minutes after you confirm the upload | Top-ups, the remaining balance, tax, and a wallet in CNY | | Claude Code, Codex and app usage | From the day you add the settings | A few minutes after you verify | Developers or apps that have not added the settings | ## Prerequisites **Monthly usage export** * A DeepSeek account with a wallet in US dollars. LevelFour does not read exports in CNY yet. * Access to the Usage page of [platform.deepseek.com](https://platform.deepseek.com/usage). **Claude Code usage** * Claude Code pointed at DeepSeek's Anthropic-compatible API, with `ANTHROPIC_BASE_URL` set to `https://api.deepseek.com/anthropic`. **Codex usage** * Codex with DeepSeek as its model provider, in a `[model_providers.deepseek]` table of `~/.codex/config.toml` with `wire_api = "responses"`, as [DeepSeek's Codex guide](https://api-docs.deepseek.com/quick_start/agent_integrations/codex) sets it up. The CLI, the IDE extension and the ChatGPT app share that file. **App usage** * An app that calls DeepSeek through the OpenAI SDK, with its base URL at `https://api.deepseek.com`. ## Connect Open **Connect Providers** and select **DeepSeek**. The first time an admin opens it, **Connect DeepSeek** starts by itself; after that, click **Add account**. Pick what your team uses and click **Continue**. Each pick adds its own step, a review at the end lists what you connected, and you can run it again at any time to add more. **Monthly usage export** ### Export a month from DeepSeek Open [Usage](https://platform.deepseek.com/usage) on the DeepSeek platform and export the month you want. DeepSeek downloads a ZIP file. There is no need to unzip it. ### Upload it In **Connect DeepSeek**, pick **DeepSeek API**. On the **Usage export** step, click **Select a file**, choose the ZIP, then click **Review costs**. Check the dates and the totals, and click **Confirm and import**. Upload one file a month into the same account. A file that covers days already imported replaces them, so uploading a month again never counts it twice. LevelFour reads one row per day, model, API key and token type (cache hit input, cache miss input, output and requests), and matches each day and model to the total DeepSeek's cost file states. An API key shows as its name and the last four characters DeepSeek leaves unmasked. The export dates each charge by DeepSeek's own billing day, in China Standard Time. **Claude Code usage** ### Generate the settings In **Connect DeepSeek**, pick **Claude Code**. On the **Claude Code** step, click **Generate settings**. LevelFour shows the same eight-entry settings block as [for Claude](/connectors/anthropic#claude-code-usage), with a new ingest key, once. ### Add it next to your DeepSeek settings Add the eight entries to the `env` object that points Claude Code at DeepSeek, next to `ANTHROPIC_BASE_URL`: * **One developer:** `~/.claude/settings.json`. * **Everyone:** an admin adds them to the managed settings file on each machine, for example through MDM: `/Library/Application Support/ClaudeCode/managed-settings.json` on macOS, `/etc/claude-code/managed-settings.json` on Linux and WSL, or `C:\Program Files\ClaudeCode\managed-settings.json` on Windows. Claude Code reads the file when it starts and again when it changes. > **Warning** > > A settings file holds one set of these entries, so Claude Code on a machine reports to one key. If the same machine also runs Claude Code against Anthropic, those sessions count here as DeepSeek usage, because both name their models after Claude. Keep the two apart on separate machines, or measure one of them. ### Verify Run one prompt in Claude Code, then click **Verify connection** in the step. DeepSeek serves a Claude model name on its own models: Opus on V4 Pro, every other Claude model on Flash. LevelFour stores and prices each request as the model DeepSeek served, and ignores the cost Claude Code reports, which is a Claude price. [What LevelFour keeps from Claude Code](/connectors/anthropic#what-levelfour-keeps-from-claude-code-and-cowork) applies here unchanged. **Codex usage** ### Generate the settings In **Connect DeepSeek**, pick **Codex**. On the **Codex** step, click **Generate settings**. LevelFour shows the same `[otel]` table as [for OpenAI](/connectors/openai#codex-usage), with a new ingest key, once. ### Add it to the file that names DeepSeek Add the block at the end of `~/.codex/config.toml`, the file that holds your `[model_providers.deepseek]` table, below every other setting. Then quit Codex and open it again: Codex reads the file when it starts. > **Warning** > > A configuration file holds one `[otel]` table, so Codex on a machine reports to one key. If the file already has an `[otel]` table, for example the one from [Codex usage on the OpenAI page](/connectors/openai#codex-usage), replacing it moves that machine's reports here. LevelFour then counts the requests DeepSeek served and leaves out the ones OpenAI served. ### Verify Run one prompt in Codex, then click **Verify connection** in the step. [What LevelFour keeps from Codex](/connectors/openai#what-levelfour-keeps-from-codex) applies here unchanged. **App usage** Follow [App usage on the OpenAI page](/connectors/openai#app-usage), with two differences: generate the variables from **Your apps** in **Connect DeepSeek**, and keep the OpenAI SDK's base URL at `https://api.deepseek.com`. The OpenAI instrumentation labels a DeepSeek call as an OpenAI one, so LevelFour reads the vendor from the host that served it. DeepSeek reports cache hits in the same fields OpenAI uses, so `opentelemetry-instrumentation-genai-openai` reports them and LevelFour prices them at DeepSeek's cache-hit rate. On `opentelemetry-instrumentation-openai-v2`, every input token counts at the cache-miss rate, up to 50 times the cache-hit price. ## Who a request belongs to Claude Code and Codex name a person only when the developer signs in to Claude or ChatGPT. With DeepSeek as the model provider, most developers sign in to neither, so each shows as a device or a machine. A developer can name themselves by setting `OTEL_RESOURCE_ATTRIBUTES=levelfour.person=` in the environment their tool runs in. For Claude Code that is the `env` object of its settings, next to the block. ## What you see DeepSeek spend appears under [**Costs and Usage**](/cost-and-usage) and on its own **DeepSeek** board under [**Boards**](/boards), which appears once you connect a source. | Group by | Monthly usage export | Claude Code, Codex and app usage | | ------------------ | --------------------------------------------------- | -------------------------------- | | Model | The model | The model DeepSeek served | | Tool | Not reported | Claude Code, Codex or Apps | | Token type | Cache hit input, Cache miss input, Output, Requests | On the board's tokens card | | Key, person or app | API key | Person, or app for app usage | **Billed** is the export. **Usage value** is what the tools report, priced at DeepSeek's list price, and LevelFour never adds it to a billed total. The DeepSeek board groups its cards the way the [OpenAI board](/connectors/openai#the-openai-board) does, with the activity Claude Code adds: lines of code, commits and pull requests, edits, plugins, skills and MCP connections. Its usage bands are sized to DeepSeek's prices. ## Rotating or removing access You cannot rotate an ingest key. Disconnect the usage source, keeping its data, generate the settings again, and replace the block or the variables. Disconnecting a usage source stops its key working at once. Disconnecting the monthly usage export keeps or purges what you imported, as you choose when you disconnect. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The upload says the export is in CNY | LevelFour reads wallets in US dollars only for now | | LevelFour marks the upload failed | The file is not the export DeepSeek downloads, or the download broke. Export the month again and upload the ZIP as it came | | A month shows twice | Upload into the account that already holds it, from its **Upload another month** action, and the new file replaces the old one | | **Verify connection** says no usage has arrived | Check the block sits in the settings the tool reads, next to its DeepSeek settings, then restart the tool and run one prompt. A request for a model DeepSeek does not serve is left out | | The account shows a warning that a model has no list price | A request used a DeepSeek model LevelFour cannot price yet. Its tokens count, at no cost, until LevelFour adds the price | | A developer shows as a device or a machine | Expected when they are not signed in to Claude or ChatGPT. Have them set `levelfour.person` | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [OpenAI](/connectors/openai) and [Anthropic](/connectors/anthropic) connect the same way --- # Cursor Source: https://docs.levelfour.ai/connectors/cursor.md ## Prerequisites * Cursor Enterprise. Team and Pro plans cannot use the Admin API that this connector reads. * A team admin, to create the API key. ## Connect ### Create a team API key in Cursor In the Cursor dashboard, go to **API Keys > Team**, and create a new key. Cursor publishes no read-only admin scope, so the key is created with full admin access. Copy the key (it starts with `crsr_`). > **Warning** > > This key can also change spend limits, remove members, and edit model access. Create it on a dedicated identity, since Cursor offers no narrower scope. ### Add it to LevelFour Open **Connect Providers**, select **Cursor**, and paste the key under **Team API key**. Click **Add Team API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new team key in Cursor, paste it into **Connect Providers**, then revoke the old key from Cursor's **API Keys** page. ### Removing access Disconnect Cursor under **Connect Providers**, then revoke the key from Cursor's **API Keys** page. ## Troubleshooting | Symptom | What to do | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The connection is refused outright | Cursor's Admin API is Enterprise only. A Team or Pro plan key will not work, even if Cursor's dashboard lets you create one | | Spend looks low compared to your invoice | Seat subscription fees are not in this data. Only usage-based model spend is covered | | The key stops working unexpectedly | Team keys belong to the organization, but a dashboard-level change to the creator's account can still affect them. Create a fresh key and check with a team admin | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Baseten Source: https://docs.levelfour.ai/connectors/baseten.md ## Prerequisites * A Baseten team. * A team admin, to create the API key. Baseten publishes no billing-only permission, so the key that reads usage can also deploy and call every model on the team. ## Connect ### Create a team API key in Baseten In the Baseten console, go to **API Keys** and create a key. Copy it. > **Warning** > > Baseten has no billing-only key. Create this key on a dedicated identity, since it can also deploy and manage models. ### Add it to LevelFour Open **Connect Providers**, select **Baseten**, and paste the key under **Team API key**. Click **Add Team API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new team key in Baseten, paste it into **Connect Providers**, then revoke the old key from Baseten's **API Keys** page. ### Removing access Disconnect Baseten under **Connect Providers**, then revoke the key from Baseten's **API Keys** page. ## Troubleshooting | Symptom | What to do | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | No data before January 2026 | Expected. Baseten's usage summary API has no data before that date | | Costs look incomplete on a team with multiple members | On teams-enabled organizations, usage is visible only to Admins. Confirm the key belongs to an Admin | | Spend is missing for some models but not others | Token-level detail for Model APIs is retained for 92 days. Older Model APIs usage will not appear even though dedicated inference and training history goes back further | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Modal Source: https://docs.levelfour.ai/connectors/modal.md ## Prerequisites * A Modal Team or Enterprise plan. The Starter plan cannot use the billing API this connector reads. * A workspace Owner or Manager, to create the token. Modal has no billing-only role: a Member cannot access billing at all, and there is no read-only scope for Owners or Managers either. ## Connect ### Create an API token in Modal In Modal's workspace settings, create a new token as an Owner or Manager. Copy the **Token ID** (`ak-...`) and the **Token secret** (`as-...`). ### Add it to LevelFour Open **Connect Providers**, select **Modal**, and paste the two values under **Token ID** and **Token secret**. Click **Add Token ID**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new token in Modal, paste it into **Connect Providers**, then revoke the old token from Modal's workspace settings. ### Removing access Disconnect Modal under **Connect Providers**, then revoke the token from Modal's workspace settings. ## Troubleshooting | Symptom | What to do | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | The token cannot read billing at all | Only Owner and Manager tokens can read workspace billing. A Member's token, or a service user with only an Environment Role, cannot | | Reported cost runs above your invoice | This connector reports cost before credits and reservations are applied. Compare against your billed total, not the report total, when reconciling | | The connection is refused on a Starter plan | Upgrade to Team or Enterprise. The billing report API this connector reads is not available on Starter | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Hugging Face Source: https://docs.levelfour.ai/connectors/huggingface.md ## Prerequisites * Hugging Face Team or Enterprise, for organization-level billing. Resource group cost attribution needs Enterprise or above. * Access to create a fine-grained user access token that can read organization billing. ## Connect ### Create a user access token in Hugging Face Go to **huggingface.co/settings/tokens** and create a fine-grained token scoped to your organization. Copy it (it starts with `hf_`). ### Add it to LevelFour Open **Connect Providers**, select **Hugging Face**, and paste the token under **User access token** and your organization's name under **Organization name**. Click **Add User access token**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new access token in Hugging Face, paste it into **Connect Providers**, then delete the old token from **huggingface.co/settings/tokens**. ### Removing access Disconnect Hugging Face under **Connect Providers**, then delete the token from **huggingface.co/settings/tokens**. ## Troubleshooting | Symptom | What to do | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | The connection fails to read billing | Confirm your organization is on Team or Enterprise, and that the token belongs to a member who can view organization billing | | Resource group breakdowns are missing | Resource group cost attribution needs Enterprise or above. On Team, you still get organization-level totals | | Compute costs on AWS Marketplace do not match | If your organization pays through AWS Marketplace, Hugging Face compute is billed through AWS instead. Connect AWS to see it | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Anyscale Source: https://docs.levelfour.ai/connectors/anyscale.md > **Warning** > > Anyscale bills in credits, not currency. This connector estimates dollars by multiplying credit usage by the rate you enter. It is only as accurate as that rate, and it will not reflect anything your contract prices differently. ## Prerequisites * An Anyscale platform organization. * An organization owner, to create a service account key. A personal API key also works but expires after at most 30 days by default and must be rotated; a service account key does not expire. * Your contracted rate per Anyscale credit, in USD. ## Connect ### Create a service account key in Anyscale As an organization owner, create a service account and generate its API key. Copy the key. ### Add it to LevelFour Open **Connect Providers**, select **Anyscale**, and paste the key under **Service account API key**. Enter your contracted rate under **Rate per Anyscale credit (USD)**. Click **Add Service account API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new service account key in Anyscale, paste it into **Connect Providers**, then revoke the old key. ### Removing access Disconnect Anyscale under **Connect Providers**, then revoke the key from Anyscale. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Spend does not match your invoice | This is an estimate: credits times the rate you entered. Anyscale itself labels this usage data as approximate, and it may differ from your final bill | | The key stops working after about a month | A personal API key expires by default. Use a service account key instead, which does not expire | | Costs are missing for a customer-hosted (BYOC) cloud | Instance charges on a BYOC cloud may be billed directly by your cloud provider rather than through Anyscale credits. Check with Anyscale support if you are unsure | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # GitHub billing Source: https://docs.levelfour.ai/connectors/github-billing.md ## Prerequisites * A GitHub organization on the enhanced billing platform. Open `github.com/organizations//billing`: if it shows the new usage pages rather than a deprecation notice, you are already on it. * An Organization Owner, to install the app. ## Connect ### Install the LevelFour Billing app Open **Connect Providers**, select **GitHub billing**, and click **Install App**. GitHub opens its install page for the LevelFour Billing app. Choose the organization to install on and approve **Administration: read**. This is a read-only grant: it does not give LevelFour access to your code. ### Confirm the organization Back in LevelFour, confirm the organization name under **Connect Providers**. Click **Connect**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating access There is no separate credential to rotate. To change which organization is connected, disconnect and reinstall. ### Removing access Open `github.com/organizations//settings/installations`, find the LevelFour Billing app, and click **Uninstall**. The connection on LevelFour's side moves to disconnected within a minute. ## Troubleshooting | Symptom | What to do | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The install picker only shows a personal account | You are not an Owner of the target GitHub organization. Ask an Owner to install, or ask them to grant you the role | | The connection is refused after install | Your organization is not yet on the enhanced billing platform. GitHub migrated all enterprises and Team-plan organizations by March 2025; check `github.com/organizations//billing` for the deprecation notice | | Some spend has no repository attached | Expected for line items like seats or shared storage that are not tied to a single repository | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [GitHub](/integrations/github) covers the separate app that reads Infrastructure-as-Code and comments on pull requests --- # GitLab billing Source: https://docs.levelfour.ai/connectors/gitlab-billing.md > **Warning** > > GitLab publishes no currency for compute minutes or credits, and Ultimate has no list seat price. This connector prices your usage from the rates you enter. It will not reflect anything your contract prices differently. ## Prerequisites * A GitLab top-level group. * An Owner of that group, to create the personal access token. * Your rate per seat per month, and, if you buy GitLab Credits, your rate per credit, in USD. ## Connect ### Create a personal access token in GitLab As an Owner of the top-level group, create a personal access token with the `read_api` scope. ### Add it to LevelFour Open **Connect Providers**, select **GitLab billing**, and paste the token, your top-level group path, your GitLab host (`gitlab.com` unless you self-host), your rate per seat per month, and, if it applies, your rate per credit. Click **Add Personal access token**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new personal access token in GitLab, paste it into **Connect Providers**, then revoke the old token. ### Removing access Disconnect GitLab billing under **Connect Providers**, then revoke the token from GitLab. ## Troubleshooting | Symptom | What to do | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Spend does not match your invoice | This is an estimate priced from the rates you entered, including seats, which GitLab has no API for. Update the rates if your contract changes | | The token only sees part of the group's usage | Confirm the personal access token belongs to a member who is an Owner of the top-level group itself, not just a subgroup or project | | The token stops working | Personal access tokens expire. Create a new one with `read_api`, update it under Connect Providers, and revoke the old one | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Slack billing Source: https://docs.levelfour.ai/connectors/slack-billing.md > **Note** > > Slack publishes no API for cost data. This is not measured usage. It is the contract amount you enter, spread across each month. ## Prerequisites None. Anyone with access to Connect Providers can add it. ## Connect ### Add your Slack contract in LevelFour Open **Connect Providers**, select **Slack billing**, and enter your monthly amount, its currency, and a start date. Add an end date if your contract will end on a known date. ### Save it Click **Save**. There is nothing else to configure. ## Confirming it worked The connection moves to **Connected**, and the amount appears in your cost views under Slack. Your first data appears within a day. ## Rotating or removing access There is no credential involved. To change the amount, edit the entry under **Connect Providers**. To stop it, either set an end date or disconnect it. ## Troubleshooting | Symptom | What to do | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | The amount does not match your latest invoice | Update the entry under Connect Providers. LevelFour has no way to read Slack's billing directly, so it only knows what you last entered | | You need it to stop appearing after a renewal date | Set an end date rather than disconnecting early, so history up to that date is preserved | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # CircleCI Source: https://docs.levelfour.ai/connectors/circleci.md > **Warning** > > CircleCI bills in credits, not currency. This connector estimates dollars by multiplying credit usage by the price you enter. It will not reflect anything your contract prices differently. ## Prerequisites * A CircleCI organization. * An Org Admin, to create the personal API token. CircleCI has no scoped, billing-only token. * Your contracted price per credit, in USD. ## Connect ### Create a personal API token in CircleCI As an Org Admin, go to **User Settings > Personal API Tokens** and create a token. Copy it, along with your Organization ID from **Organization Settings > Overview**. > **Warning** > > This token is tied to the person who created it and carries their full access. Use a dedicated Org Admin identity, since the token breaks if that person leaves the organization. ### Add it to LevelFour Open **Connect Providers**, select **CircleCI**, and paste the token, the Organization ID, and your price per credit. Click **Add Personal API token**. ## Confirming it worked The connection moves to **Connected**, with first data typically landing 3 to 6 hours after you connect. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new personal API token in CircleCI, paste it into **Connect Providers**, then revoke the old token from **User Settings > Personal API Tokens**. ### Removing access Disconnect CircleCI under **Connect Providers**, then revoke the token from CircleCI. ## Troubleshooting | Symptom | What to do | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Spend does not match your invoice | This is an estimate: credits times the price you entered. Compare against CircleCI's own Plans UI, which is the billing source of truth | | The token stops working | Personal tokens belong to one person and are revoked if that person is removed from the organization. Recreate it under a dedicated Org Admin identity | | Backfill is slow to complete | CircleCI limits usage report requests to 10 per hour per organization, so a full 12-month backfill takes time. It completes in the background | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Temporal Cloud Source: https://docs.levelfour.ai/connectors/temporal.md ## Prerequisites * A Temporal Cloud account. * An Account Owner or Global Admin, to create the service account and its API key. ## Connect ### Create a service account in Temporal Cloud As an Account Owner, go to **Settings > Identities** and create a service account with the account-level role **Finance Admin**, and Read-Only access on each namespace. Generate an API key for that service account under **Settings > API keys**, and copy it. ### Add it to LevelFour Open **Connect Providers**, select **Temporal Cloud**, and paste the API key. Click **Add Service account API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Generate a new API key for the same service account in Temporal Cloud, paste it into **Connect Providers**, then revoke the old key. ### Removing access Disconnect Temporal Cloud under **Connect Providers**, then revoke the API key or delete the service account in Temporal Cloud. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | The service account cannot be created | Only an Account Owner or Global Admin can create a service account and grant it Finance Admin. A Namespace Admin cannot | | The connection stops working after a couple of years | Temporal Cloud API keys expire after at most two years. Temporal emails the account before expiry; rotate the key when you get that notice | | A namespace added later is missing | Namespace permissions are not automatic. Grant the service account Read-Only access on any namespace added after the initial setup | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Twilio Source: https://docs.levelfour.ai/connectors/twilio.md ## Prerequisites * A Twilio account. * An Administrator or Developer, to create the API key. ## Connect ### Create an API key in Twilio While signed into your main Twilio account, create a Restricted API key scoped to Usage Records if your account allows it. Otherwise, create a Standard API key. Copy the API Key SID, the API Secret, and your Account SID. > **Note** > > A key created in the main account rolls up costs from every subaccount, as if the parent incurred them. If you need per-subaccount attribution instead, connect each subaccount separately with its own key. ### Add it to LevelFour Open **Connect Providers**, select **Twilio**, and paste the three values. Click **Add Account SID**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new API key in Twilio, paste it into **Connect Providers**, then delete the old key. ### Removing access Disconnect Twilio under **Connect Providers**, then delete the API key from Twilio's console. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Subaccount spend appears twice | You connected the main account (which already rolls up subaccounts) and one or more subaccounts separately. Connect one or the other, not both | | Total spend does not exactly match the invoice total | Some Twilio charges fall outside the standard usage categories. Reconcile against the overall total, not the sum of categories | | Recent days keep changing | Expected. Today's and the current month's usage are not final until Twilio closes them | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Datadog Source: https://docs.levelfour.ai/connectors/datadog.md ## Prerequisites * Datadog Pro or Enterprise. * The parent organization. Datadog's cost endpoints only work from a parent-level org: a sub-organization key is refused. * A Datadog admin, to create an application key scoped to usage and billing reads. ## Connect ### Create an API key and a scoped application key in Datadog In your Datadog parent organization, copy your organization API key from **Organization Settings > API Keys**. Then create a service account and an application key for it, scoped to `usage_read` and `billing_read`. Both scopes are required: either alone is refused. ### Add it to LevelFour Open **Connect Providers**, select **Datadog**, and paste the API key, the application key, and your Datadog site (for example `datadoghq.com` or `datadoghq.eu`). Click **Add API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new application key in Datadog, paste it into **Connect Providers**, then revoke the old key. ### Removing access Disconnect Datadog under **Connect Providers**, then revoke the application key from Datadog's **Organization Settings**. ## Troubleshooting | Symptom | What to do | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | The connection is refused with a permissions error | Confirm the application key has both `usage_read` and `billing_read`. Either alone is not enough | | The connection is refused even with both scopes | Datadog's cost endpoints only work from the parent-level organization. A key created in a sub-organization will not work; create it from the parent org | | Recent days keep changing | Expected for products Datadog bills on peak (p95/p99) usage. A month is not final until about 17 days after it closes | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Snowflake Source: https://docs.levelfour.ai/connectors/snowflake.md ## Prerequisites * An organization account, or a regular account with ORGADMIN enabled. Only these can read the organization usage views this connector uses. * ACCOUNTADMIN access, to run the setup script. * Reseller-contracted Snowflake accounts cannot read the organization billing views, so this connector will not work for them. ## What it costs you This connector runs one small scheduled query a day, on a dedicated XS warehouse with auto-suspend after 60 seconds. Expect a few cents a month on your Snowflake bill. ## Connect ### Get your setup script Open **Connect Providers**, select **Snowflake**, and enter your account identifier (for example `myorg-myaccount`). LevelFour generates a key pair and a SQL script scoped to your account. ### Run the script in Snowflake As ACCOUNTADMIN, open a Snowflake worksheet, paste the script, and run it. The script creates a dedicated `TYPE = SERVICE` user authenticated by the key pair LevelFour generated, a new XS warehouse with `AUTO_SUSPEND = 60`, and grants that user read access only to the organization usage views LevelFour reads. It changes nothing else in your account. ### Finish in LevelFour Return to **Connect Providers** and click **Verify**. The connection completes once the script has run. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Generate a new script from **Connect Providers**. Running it issues a new key pair for the same service user; the old key stops being accepted once the new one is set. ### Removing access Disconnect Snowflake under **Connect Providers**, then, as ACCOUNTADMIN, drop the dedicated user and warehouse the script created. ## Troubleshooting | Symptom | What to do | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | The script runs, but no currency data ever appears | Your account is likely reseller-contracted. Reseller accounts cannot read the organization billing views; check with your Snowflake account team | | The script fails to run | It must run as ACCOUNTADMIN. A role with lesser privileges cannot grant the organization usage views | | You connected a regular account and only see one account's data | Currency data is only available from the organization account or an ORGADMIN-enabled account. A plain account sees credits for itself only, never currency | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [Databricks](/connectors/databricks) connects the same way, with a setup script instead of a pasted key --- # Databricks Source: https://docs.levelfour.ai/connectors/databricks.md ## Prerequisites * A Unity Catalog enabled workspace. * Account admin and metastore admin rights. You need both to run the setup script and grant the service principal access to `system.billing`. A workspace admin alone is not enough. ## What it costs you This connector runs one small scheduled query a day, on a serverless SQL warehouse with auto-stop. The query itself costs a small amount of serverless compute time; the system tables it reads are free. ## Connect ### Get your setup script Open **Connect Providers**, select **Databricks**, and click **Get setup script**. ### Run the script in Databricks As an account admin, run the script in your workspace. It creates a dedicated service principal with an OAuth secret (valid for up to 730 days), grants that service principal Data Reader on `system.billing`, and creates or reuses a small serverless SQL warehouse with auto-stop, granting the service principal Can Use on it. ### Add the service principal to LevelFour Copy the service principal's client ID and the OAuth secret the script prints, along with your workspace host (for example `yourcompany.cloud.databricks.com`) and the SQL warehouse ID. Paste them under **Connect Providers** and click **Verify**. ## Confirming it worked The connection moves to **Connected**, usually within about 12 hours of the script finishing, since Databricks itself takes time to deliver billing records. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Generate a new OAuth secret for the service principal in Databricks, paste it into **Connect Providers**, then revoke the old secret. ### Removing access Disconnect Databricks under **Connect Providers**, then, as an account admin, revoke the service principal's grants and delete it. ## Troubleshooting | Symptom | What to do | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cost figures are all at list price | Negotiated rates come from a Databricks table that is in private preview. Ask your Databricks account team to enable it; until then, figures reflect list price | | The script fails to grant access | You need both account admin and metastore admin rights to grant `system.billing` by default. A workspace admin alone is not enough | | The connection stops updating after about two years | The OAuth secret expires after at most 730 days. Rotate it before it lapses | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [Snowflake](/connectors/snowflake) connects the same way, with a setup script instead of a pasted key --- # ClickHouse Cloud Source: https://docs.levelfour.ai/connectors/clickhouse.md ## Prerequisites * A ClickHouse Cloud organization. * Access to create an organization API key with the Billing role. ## Connect ### Create an API key in ClickHouse Cloud In the ClickHouse Cloud console, go to **Settings > API Keys > New API key**. Assign the **Billing** and **Service** reader roles, set an expiration, and generate the key. Copy the Key ID, the Key Secret, and your Organization ID. ### Add it to LevelFour Open **Connect Providers**, select **ClickHouse Cloud**, and paste the three values. Click **Add API key ID**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new API key in ClickHouse Cloud, paste it into **Connect Providers**, then delete the old key. ### Removing access Disconnect ClickHouse Cloud under **Connect Providers**, then delete the API key from ClickHouse Cloud's **Settings > API Keys**. ## Troubleshooting | Symptom | What to do | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | The connection fails with a forbidden error | The key needs the Billing role specifically. Service reader alone is not enough to read cost data | | The connection stops working after the expiration date | ClickHouse Cloud API keys expire on the date you set. Create a new one and update it under Connect Providers | | Costs also show up on your cloud marketplace invoice | If your organization is billed through AWS, GCP, or Azure Marketplace, ClickHouse spend appears there too. Do not add the two together | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # MongoDB Atlas Source: https://docs.levelfour.ai/connectors/mongodb.md ## Prerequisites * A MongoDB Atlas organization. * Organization Owner access, to create the service account. * If your organization enforces an API access list, ask your LevelFour contact for the addresses to allow. ## Connect ### Create a service account in MongoDB Atlas As an Organization Owner, create a service account with the **Organization Billing Viewer** and **Organization Read Only** roles. Copy its client ID and secret. ### Add it to LevelFour Open **Connect Providers**, select **MongoDB Atlas**, and paste the client ID, client secret, and your Organization ID. Click **Add Service account client ID**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new service account in MongoDB Atlas, paste its credentials into **Connect Providers**, then delete the old service account. ### Removing access Disconnect MongoDB Atlas under **Connect Providers**, then delete the service account from Atlas. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | The connection is refused | Only an Organization Owner can create a service account with billing access. Confirm the creator's role | | The connection fails behind an API access list | Your organization restricts API access by IP. Ask your LevelFour contact for the addresses to allow, then retry | | The current month's spend keeps changing | Expected. The in-progress invoice is not final until it closes; a prior month stops changing once its invoice is issued | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Redis Cloud Source: https://docs.levelfour.ai/connectors/redis.md ## Prerequisites * A Redis Cloud account with the REST API enabled. An account Owner must turn this on: it is off by default. * A user with the Viewer role, to create the user key. * Cost report access may need a Redis support request. See [Troubleshooting](#troubleshooting). ## Connect ### Enable the API and create keys in Redis Cloud As an account Owner, enable the REST API in **Access Management > API Keys**, and copy the account key. Then create or pick a user with the Viewer role and generate a user key for it. ### Add them to LevelFour Open **Connect Providers**, select **Redis Cloud**, and paste the account key and the user key. Click **Add Account API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Generate a new user key in Redis Cloud, paste it into **Connect Providers**, then delete the old key. Changing the account key requires a Redis support request. ### Removing access Disconnect Redis Cloud under **Connect Providers**, then delete the user key from Redis Cloud's **Access Management**. ## Troubleshooting | Symptom | What to do | | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The connection fails with a permission error on the cost report | Some accounts need a Redis support request to turn on cost report access, separately from the REST API itself. Contact Redis support and ask them to enable it | | The API key fields are missing entirely | The REST API is off by default. An account Owner has to turn it on first, in Access Management | | Network cost looks late or missing for a Pro subscription | Pro network costs can lag up to 72 hours and were not tracked before November 2025 | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Grafana Cloud Source: https://docs.levelfour.ai/connectors/grafana.md ## Prerequisites * A Grafana Cloud organization. * The Cloud Portal Admin role, to create the access policy. ## Connect ### Create an access policy token in Grafana Cloud In the Grafana Cloud portal, go to **Security > Access Policies > Create access policy**. Set the realm to your whole organization, and add the scopes `billing-metrics:read` and `orgs:read`. Create a token on the policy and copy it, along with your organization slug from the grafana.com org URL. ### Add it to LevelFour Open **Connect Providers**, select **Grafana Cloud**, and paste the token and org slug. Click **Add Access policy token**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new token on the same access policy in Grafana Cloud, paste it into **Connect Providers**, then delete the old token. ### Removing access Disconnect Grafana Cloud under **Connect Providers**, then delete the token or the access policy in Grafana Cloud. ## Troubleshooting | Symptom | What to do | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | The connection fails with a permission error | Confirm the access policy has both `billing-metrics:read` and `orgs:read` | | No data appears until well into the next month | Expected. Grafana Cloud only finalizes a month's billed usage on the first of the following month | | The token stops working | Access policy tokens can be set to expire. Check the policy's expiration and issue a new token before it lapses | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # New Relic Source: https://docs.levelfour.ai/connectors/newrelic.md > **Warning** > > New Relic's usage API returns no cost or currency field. This connector estimates dollars by multiplying ingested GB and user counts by the rates you enter. It will not reflect anything your contract prices differently. ## Prerequisites * A New Relic organization on usage-based pricing. * Access to your organization's reporting account (usually the first account created), to create the user key. * Your contracted rate per GB ingested, and your Full Platform and Core user rates, in USD. ## Connect ### Create a user key in New Relic Sign in as a user with access to your reporting account. Open the API keys page and create a User key. > **Warning** > > A New Relic user key is tied to the person who created it. Use a dedicated integration user, since the key stops working if that person leaves. ### Add it to LevelFour Open **Connect Providers**, select **New Relic**, and paste the user key, your reporting account ID, your region (US or EU), and your contracted rates. Click **Add User key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new user key in New Relic, paste it into **Connect Providers**, then delete the old key. ### Removing access Disconnect New Relic under **Connect Providers**, then delete the user key from New Relic. ## Troubleshooting | Symptom | What to do | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Spend does not match your invoice | This is an estimate. New Relic's own usage data is approximate and will not reconcile exactly to your bill | | The connection fails for an EU organization | Confirm you selected the EU region under Connect Providers. US and EU accounts use different endpoints | | The user key stops working | The key belongs to the person who created it and breaks if their account changes. Create it under a dedicated integration user | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Elastic Cloud Source: https://docs.levelfour.ai/connectors/elastic.md > **Warning** > > Elastic Consumption Units (ECU) are a usage measure, not your invoiced amount. One ECU is nominally $1.00, but ECU does not reflect prepaid discounts or currency conversion. Treat this data as an estimate of usage value, not a match to your bill. ## Prerequisites * Elastic Cloud Hosted or Serverless. * An Organization owner, to create the API key with the Billing admin role. ## Connect ### Create an API key in Elastic Cloud As an Organization owner, create an API key scoped to the Billing admin organization role, with an expiration of at most one year. Copy the key and your Organization ID. ### Add it to LevelFour Open **Connect Providers**, select **Elastic Cloud**, and paste the key and Organization ID. Click **Add API key**. ## Confirming it worked The connection moves to **Connected**. Your first data appears within a day. ## Rotating or removing access ### Rotating the credential Create a new API key in Elastic Cloud, paste it into **Connect Providers**, then delete the old key. ### Removing access Disconnect Elastic Cloud under **Connect Providers**, then delete the API key from Elastic Cloud's organization settings. ## Troubleshooting | Symptom | What to do | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Key creation is refused | Only an Organization owner can mint an API key, regardless of who holds the Billing admin role | | ECU totals do not match your invoice | Expected. ECU measures usage value at a nominal $1.00 each and does not reflect prepaid discounts or currency conversion | | The connection stops after about a year | API keys expire at whatever date you set, up to one year. Create a new one before it lapses | ## Next * [Connect Providers](/connectors) lists every provider you can connect --- # Custom FOCUS import Source: https://docs.levelfour.ai/connectors/custom-focus.md ## Prerequisites * A cost file in FOCUS format (version 1.1 through 1.3), with at minimum `ChargeCategory`, `ChargePeriodStart`, `BilledCost`, and `ServiceName` columns. Add `ChargePeriodEnd` if any of your costs are amortized. * The file must be 100 MB or smaller. Split a larger export into multiple files and upload them one at a time. ## Connect ### Prepare your FOCUS file Export or build a CSV with the required FOCUS columns. Most billing systems that publish a FOCUS export can produce this directly. ### Upload it to LevelFour Open **Connect Providers**, select **Custom provider > Upload a FOCUS CSV**, name the provider, and upload the file. Click **Confirm and import**. ## Confirming it worked The connection moves to **Connected**, and the rows from your file appear in your cost views under the provider name you chose. Your first data appears within a day. ## Rotating or removing access There is no credential to rotate. To remove the data, disconnect the provider under **Connect Providers**. ## Troubleshooting | Symptom | What to do | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | The upload is refused | Confirm the file is under 2 MB and 10,000 rows, and that it has the required FOCUS columns with those exact names | | Amortized costs look wrong | Include `ChargePeriodEnd` for any row that represents an amortized cost, not just `ChargePeriodStart` | | You need to add more history later | Upload another file. Each upload adds rows; it does not replace what is already there | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [Add a recurring cost](/connectors/custom-recurring) is simpler for a flat monthly charge with no per-line detail --- # Add a recurring cost Source: https://docs.levelfour.ai/connectors/custom-recurring.md ## Prerequisites None. Anyone with access to Connect Providers can add a recurring cost. ## Connect ### Add the cost in LevelFour Open **Connect Providers**, select **Custom provider > Add a recurring cost**, and enter a provider name, a service name, the monthly amount, its currency, and a start date. Add an end date if the cost will stop on a known date. ### Save it Click **Save**. There is nothing else to configure. ## Confirming it worked The connection moves to **Connected**, and the amount appears in your cost views under the provider and service name you entered. Your first data appears within a day. ## Rotating or removing access There is no credential involved. To change the amount, edit the entry under **Connect Providers**. To stop it, either set an end date or disconnect it. ## Troubleshooting | Symptom | What to do | | -------------------------------------------------- | ------------------------------------------------------------------------------------------ | | The amount does not appear in a month you expected | Check the start and end dates. The cost only spreads across months inside that range | | You need per-line detail instead of a flat amount | Use [Upload a FOCUS CSV](/connectors/custom-focus) instead, if the provider can export one | ## Next * [Connect Providers](/connectors) lists every provider you can connect * [Upload a FOCUS CSV](/connectors/custom-focus) covers per-line detail instead of a flat amount --- # All integrations Source: https://docs.levelfour.ai/integrations.md An integration gives LevelFour somewhere to write: a comment on a pull request, an alert in a channel, a ticket in a backlog. [Providers](/connectors) are the other half of onboarding, and they are what LevelFour reads. You connect all four from **Settings**, then **Integrations**, at `https://dashboard.levelfour.ai/control-plane/integrations`. - [GitHub](/integrations/github): Comments cost impact on your pull requests, and opens pull requests with savings changes. - [GitLab](/integrations/gitlab): The same on merge requests. - [Slack](/integrations/slack): Answers questions about your Cloud, SaaS and AI spend, and posts your cost alerts. - [Jira](/integrations/jira): Files an issue from a savings recommendation, so the work is scheduled and owned. [Pull request bot](/integrations/pull-request-bot) covers what LevelFour posts on a pull request or merge request once GitHub or GitLab is connected, and the commands it answers. You authorize each one in its own tab, then come back. None of them asks you to paste a token. ## Before you start You need admin rights in the tool you are connecting: an Owner of the GitHub organization, a GitLab user who can authorize applications, a Slack workspace admin, or an Atlassian site admin. GitHub and GitLab are per-plan. If a tile reads **Contact sales**, that integration is not enabled for your organization yet, and clicking it tells you how to ask. Slack and Jira are on every plan. ## Not built yet The grid also shows Bitbucket, Microsoft Teams, Opsgenie and PagerDuty. Clicking one records that you want it. Nothing connects today. ## Next * [Connect Providers](/connectors) is where cost and usage data comes from * [Quickstart](/quickstart) is the order to set everything up in --- # GitHub Source: https://docs.levelfour.ai/integrations/github.md LevelFour installs as a single GitHub App on the organizations and repositories you select. One install covers both GitHub-side products: the bot that comments cost impact on the pull requests your team opens, and the savings pull requests LevelFour opens against your default branch. > **Note** > > Granting access to a repository opts it into both, and the bot is on by default. Per-repo overrides live in [`.levelfour/config.yml`](/guides/pr-bot-configuration). ## Prerequisites * You are an **Owner** of the GitHub organization. Members and outside collaborators cannot install third-party apps. * Your organization does not block third-party GitHub Apps under **Settings > Third-party access**. Most organizations leave this open. * You are signed in to `https://dashboard.levelfour.ai` as a member of the LevelFour organization you are connecting to. ## Install ### Open Settings, then Integrations In the dashboard sidebar, click **Settings**, then **Integrations**, then the **GitHub** tile. If the tile reads **Contact sales**, the GitHub products are not enabled for your organization yet. Click it to ask. ### Install the GitHub App Click **Install GitHub App**. A new tab opens GitHub's install page. GitHub asks two questions. **Which account** to install on: pick the organization you intend to connect. If the picker shows only your personal account, you are not an Owner, or the organization restricts third-party apps. **Which repositories** to grant. **All repositories** covers every repo now and any added later. **Only select repositories** covers the ones you pick, which suits a phased rollout. Click **Install**. GitHub returns you to LevelFour, and you can close that tab once it says you can. ### Wait for the panel to flip to Connected The dashboard tab polls for the installation, moving through `Waiting for install` and `Verifying installation`. > **Success** > > The pill lands on `Connected` within about ten seconds, and the installation appears with its repository count. Comments on new pull requests start right away. The first pass over your existing Infrastructure-as-Code takes a few minutes. ## What the GitHub App grants | Permission | Why we ask for it | | ---------------------------------- | -------------------------------------------------------------------------------------- | | Read repository metadata | List the repositories you granted | | Read and write repository contents | Read your Terraform files and CloudFormation templates, and open savings pull requests | | Read issues | Correlate bot comments with related issues | | Read and write pull requests | Comment cost impact, and update our own pull requests | | Read and write check runs | Post a LevelFour status check in the PR's checks panel | > **Note** > > The app does not request admin scopes, secrets, environments, or actions configuration. ## Tuning the bot Commit a `.levelfour/config.yml` at the root of a repository's default branch. [PR bot configuration](/guides/pr-bot-configuration) has the schema and the defaults. ## Changing or removing access You change what LevelFour can see from inside GitHub. Open `https://github.com/organizations//settings/installations` and click **Configure** next to the LevelFour app. The dashboard's **Manage in GitHub** link goes straight there. Adjust **Repository access** and save. GitHub notifies LevelFour immediately, and the dashboard shows the new count on the next page load. To remove the app, click **Uninstall** at the bottom of the same page. LevelFour stops scanning within a minute. Comments and check runs already posted stay where they are. ## Troubleshooting | Symptom | What to do | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The install picker shows only your personal account | You are not an Owner, or the organization restricts third-party apps. Ask an Owner to install, or to lift the restriction under **Settings > Third-party access** | | The dashboard is stuck on `Waiting for install` | The popup was probably blocked. Click **Reopen GitHub install**. If GitHub returned a 404, contact support | | `Verifying installation` lasts more than a minute | Click **Cancel**, then **Install GitHub App** again. If it recurs, contact support with the `integration` query parameter from the URL | | You uninstalled but the installation still shows | Refresh after 30 seconds. If the row persists, contact support with the GitHub organization login | ## Next * [Pull request bot](/integrations/pull-request-bot) is what the bot posts and the commands it answers * [PR bot configuration](/guides/pr-bot-configuration) is the `.levelfour/config.yml` schema * [Integrations](/integrations) lists the other tools you can connect --- # GitLab Source: https://docs.levelfour.ai/integrations/gitlab.md LevelFour connects to GitLab over OAuth. You name the projects it should read, authorize once, and LevelFour registers a webhook on each one. It works with GitLab SaaS and with a self-managed instance. ## Prerequisites * **Maintainer** or **Owner** on every project you connect. LevelFour registers a webhook, and lower roles cannot. * A GitLab tier that allows project webhooks. LevelFour reports the ones that do not, project by project. * The full path of each project, in the form `group/subgroup/project`. ## Connect ### Open Settings, then Integrations In the dashboard sidebar, click **Settings**, then **Integrations**, then the **GitLab** tile. If the tile reads **Contact sales**, GitLab is not enabled for your organization yet. Click it to ask. ### List your projects Add the full path of each project where LevelFour should look for Infrastructure-as-Code. Add as many as you want. One authorization covers all of them. Leave the instance URL as `https://gitlab.com` for GitLab SaaS. For a self-managed instance, enter its URL, such as `https://gitlab.example.com`. ### Authorize in GitLab Click **Connect GitLab**. GitLab opens in a new tab and asks you to authorize LevelFour. Approve it, then return to the dashboard tab. The status pill moves through `Waiting for authorization` and `Verifying connection`. Authorization finishes in under a minute. If you closed the GitLab tab before approving, click **Cancel** and start again. ### Read the connection results > **Success** > > The pill lands on `Connected`, and the results panel says how many of your projects connected. A project that did not connect says why. The three you are most likely to see: | Result | What to do | | ------------------------------------------ | ---------------------------------------------------------------- | | LevelFour needs maintainer or owner access | Raise your role on that project in GitLab, then connect it again | | The tier does not allow the webhook | Upgrade the tier, or connect a different project | | We could not find this project | Check the path, including every subgroup | ## Removing access Open the connection under **Settings > Integrations > GitLab** and click **Disconnect**. LevelFour stops analyzing merge requests for that path and deletes its webhook from GitLab. If GitLab refuses the deletion, the dashboard says so, and you remove the webhook yourself under the project's **Settings > Webhooks**. You can reconnect a project at any time. ## Troubleshooting | Symptom | What to do | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | The pill sits on `Waiting for authorization` | The GitLab tab was closed or blocked. Click **Reopen GitLab authorization** | | The pill reads `Expired` | The request timed out. Start a new one for a fresh link | | The pill reads `Failed` | Someone closed the tab before authorizing, or permissions changed inside GitLab. Click **Try again** | | A self-managed instance never answers | Check that the URL is `https` and that the instance is reachable from the public internet | ## Next * [Pull request bot](/integrations/pull-request-bot) is what the bot posts on merge requests and the commands it answers * [Integrations](/integrations) lists the other tools you can connect * [GitLab billing](/connectors/gitlab-billing) is separate, and it reads your GitLab spend --- # Pull request bot Source: https://docs.levelfour.ai/integrations/pull-request-bot.md Once [GitHub](/integrations/github) or [GitLab](/integrations/gitlab) is connected, the LevelFour bot reads every pull request or merge request that changes Terraform or CloudFormation. It posts the cost impact of the change back on the request, and it proposes cheaper settings where it finds them. It behaves the same on both providers. ## What it reads | Language | What the bot prices | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Terraform** | The resources in your `.tf` files | | **CloudFormation** | YAML and JSON templates, SAM templates, nested stacks whose `TemplateURL` or `Location` names a file in the repository, and `AWS::Include` snippets from the repository | A request that changes both gets one cost comment covering both. [How it prices a CloudFormation template](#how-it-prices-a-cloudformation-template) covers parameters, regions and what the bot cannot read. ## What it posts | Item | Where | What it shows | | ---------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | **Cost comment** | The conversation | The cost of the changed resources before and after, and the monthly spend. The bot edits this one comment on each push instead of adding new ones | | **LevelFour status** | The checks panel on GitHub, the pipeline status on GitLab | That the analysis ran, and its summary | | **Inline suggestions** | On lines in the changed hunks of the diff | A concrete change to a resource, ready to apply | The **LevelFour** status reports the analysis, not a budget. On GitHub it passes, or reads neutral when the bot has suggestions for you. On GitLab it passes. It fails only when the analysis itself could not run, so it is not a gate on cost. The bot posts a suggestion inline on any line of the diff it can comment on, context lines inside a changed hunk included. In a CloudFormation template the value must be written in the resource's own properties: a value that comes from a Parameter default or a Mapping gets no inline suggestion. A change that touches no Terraform or CloudFormation resources gets no cost comment, and the status says why. ## How it prices a CloudFormation template The bot resolves parameters, conditions, mappings and intrinsic functions before it prices a template. It prices a nested stack inside the template that creates it, with the parameters that template passes down. Parameter values and the region come from `.levelfour/config.yml`, then from `samconfig`, then from the template's own defaults, with `us-east-1` as the last resort for the region. [PR bot configuration](/guides/pr-bot-configuration#cloudformation) has the order and the parameters file formats. When the estimate rests on something the bot had to assume, the cost comment lists it under **Assumptions**: * a parameter priced at its default * a condition the bot could not decide, and assumed to hold * a value CloudFormation reads at deploy time: `Fn::ImportValue`, a parameter stored in Systems Manager, a dynamic reference such as `{{resolve:ssm:...}}` Read the list before you trust the total. If a stack deploys with values other than its defaults, map its parameters file under `cloudformation.parameter_files`. > **Note** > > The bot does not price: > > * **AWS CDK apps.** Pricing one needs `cdk synth`, which the bot does not run. Run it yourself and point [`l4 estimate`](/cli/commands/estimate#cloudformation) at the synthesized templates. > * **Serverless Framework** projects (`serverless.yml`). > * **Registry modules**, the `::MODULE` resource types. ## Reply with a command Mention the bot in a comment on the pull request or merge request, followed by a command. The bot reacts to your comment when it picks it up, then replies or updates the cost comment. Anyone who can comment can run `help`. The commands that change something (`dismiss`, `snooze`, `ignore` and `apply`) need write access to the repository on GitHub, or the Developer role or above on GitLab. The bot answers anyone else with a note saying so. | Command | What it does | | ----------------------------------- | --------------------------------------------------------------------- | | `@levelfourai help` | Posts the list of commands | | `@levelfourai dismiss ` | Dismisses the bot's suggestions on this request. A reason is required | | `@levelfourai snooze ` | Hides the suggestions until the next push. A reason is required | | `@levelfourai ignore .` | Leaves one resource out of future analyses | | `@levelfourai apply all` | Commits every inline suggestion to the request's branch | | `@levelfourai apply .` | Commits only the suggestions for that resource | `.` names the resource in its own language. For Terraform it is the resource address from your `.tf` files. For CloudFormation it is the resource type, a dot, and the logical ID: ```text @levelfourai ignore aws_ebs_volume.logs @levelfourai ignore AWS::EC2::Volume.Logs ``` Two stacks can declare the same logical ID. Add `@` and the stack's scope to name the resource in one stack only. A root stack's scope is its template path. A nested stack's scope is the root template path followed by `/` and the logical ID of each nested stack resource on the way down, never the nested template's own path: ```text @levelfourai ignore AWS::EC2::Volume.Logs@stacks/app.yaml @levelfourai ignore AWS::RDS::DBInstance.Primary@stacks/app.yaml/Database @levelfourai ignore AWS::RDS::DBInstance.Replica@stacks/app.yaml/Database/ReadReplicas ``` The second line names `Primary` in the stack that `stacks/app.yaml` creates through its `Database` resource. The third goes one level deeper, through `ReadReplicas` inside that stack. Dismissing, snoozing and ignoring add a line to the top of the cost comment naming who did it, and the reason for a dismiss or snooze, so the next reviewer sees the decision. ### Applying suggestions `apply` writes the suggestions into one commit on the branch and replies with its short SHA. On GitLab it also resolves the suggestion threads it applied. It does not run on a pull request or merge request from a fork, because the fork's branch belongs to its author. On GitHub, if the branch moved after the bot's last analysis, the bot refuses rather than overwrite newer work. Push, let the cost comment update, and run `apply` again. `apply` also needs the LevelFour app to hold write access to repository contents. If it does not, the bot says so, and an organization owner approves the permission in GitHub. ## Tune it per repository A `.levelfour/config.yml` at the root of the default branch turns the bot off for a repository, drops the inline suggestions, caps how many it posts, skips paths and resource types, or tells the bot which parameters file a template deploys with. [PR bot configuration](/guides/pr-bot-configuration) has every field. ## Next * [PR bot configuration](/guides/pr-bot-configuration) is the `.levelfour/config.yml` reference * [GitHub](/integrations/github) and [GitLab](/integrations/gitlab) cover connecting each provider * [`l4 estimate`](/cli/commands/estimate) prices the same templates from a terminal or a CI job --- # Slack Source: https://docs.levelfour.ai/integrations/slack.md LevelFour joins Slack as an agent. Open it from its **Messages** tab, or mention `@levelfour` in any channel, and ask about your Cloud, SaaS and AI spend. It answers in a thread under your message, from your organization's own data, and you can follow up in that thread. It also posts your [cost alerts](/cost-alerts): in the channels each alert names, and as a direct message to each person who turns Slack on for them. Every organization can connect it. ## Prerequisites * You can install apps in the Slack workspace. Many workspaces reserve this for admins, or require an admin to approve the request. * You are signed in to `https://dashboard.levelfour.ai` as a member of the LevelFour organization you are connecting to. ## Connect ### Open Settings, then Integrations In the dashboard sidebar, click **Settings**, then **Integrations**, then the **Slack** tile. ### Add to Slack Click **Add to Slack**. Slack opens in a new tab with the permission screen. Approve the requested access. Two kinds of permission are optional: * **Reading messages.** Decline it and the bot still answers whenever you mention `@levelfour`. Approve it and you can follow up in its threads without the mention, and message it directly. * **Agent view.** Approve it and LevelFour shows suggested prompts in its **Messages** tab, shows what it is reading while it answers, and gets a stop button. Decline it and answers arrive as ordinary replies. ### Ask your first question Come back to the dashboard tab and wait for the panel to read **Connected**. Then invite the bot to a channel and ask it something: ```text /invite @levelfour @levelfour what are our top 5 savings? ``` It answers in a thread under your message. ## Asking questions * **Start a conversation** by mentioning `@levelfour` in any channel it belongs to. It opens a thread under your message and answers there. * **Follow up** by replying in that thread. With the thread-reading permissions approved you do not need to mention it again. A reply that mentions someone else is left to them. * **Ask inside someone else's thread** by mentioning it there. It answers that one question and leaves the rest of the thread to the people in it. * **Message it directly** from the LevelFour app's **Messages** tab in Slack. It answers in a thread under your message. * **Check your setup** with `/levelfour help`, or send `help` in a message. It shows how to ask, which LevelFour organization the workspace is connected to, and whether your Slack account is linked. In a channel, only you see the reply. * **Pass something on** to the LevelFour team, such as a feature request or a problem: ask the bot to pass it on, and it sends the team your words. The bot answers as you. Your Slack email has to belong to a member of the LevelFour organization, and your LevelFour role has to include the FinOps agent. When either is missing, it tells you privately what to fix. ### While it answers * **Progress.** LevelFour shows a card for each kind of data it reads, such as your forecast or your savings, and ticks it off when the read finishes. It checks the answer against your data before showing it, so a figure never appears and then vanishes. * **Quick follow-ups.** Send a follow-up while it is still answering and it waits for that answer, then builds on it. A third message in the meantime gets a note to ask again. * **Stop.** Stop a question you asked once you no longer need it. It stops reading and says so. Only the person who asked can stop an answer. Reply `continue` in the thread and it answers the question you stopped. * **Rating.** Each answer carries **Good answer** and **Not right**. Your vote is your own, whoever asked the question. **Not right** sends the LevelFour team the question and the answer, and shows only you a **Tell us what was wrong** button. Its short form sends the team what you type. LevelFour writes its answers with AI from your organization's own data. Check important figures in LevelFour before acting on them. > **Note** > > The suggested prompts, the progress cards and the stop button need a paid Slack plan. Mentions and direct messages work on every plan. ### Making changes The bot reads and explains. To accept or reject a saving, change a cost alert or edit a tag, use LevelFour: ask the bot and it links you to the right page. ## Cost alerts in Slack The bot posts nothing on its own except the cost alerts your organization arms. Each alert reaches Slack in two ways. * **Channels.** Each alert picks its own channels, up to 10, under **Slack channels** in its form, and each gets one post when the alert fires. The list shows the public channels and the private ones `@levelfour` is already in, so invite it to a private channel first. * **A direct message.** Each person turns this on for themselves with the **Slack** switch on the **Cost alert** row of **Settings > Notification preferences**, or in an alert's form under **Your Slack DMs**. LevelFour looks them up in the workspace by their email. When it cannot message them, the switch in the alert's form stays off and says why. Until the organization connects Slack, the **Slack** switches on **Notification preferences** are grayed out. A direct message only reaches someone the alert names as a recipient. Every other notification can reach Slack the same way: mentions, approvals, applied and failed savings, renewals and setup reminders each have a **Slack** switch on their row. See [Notifications](/notifications). ### The default channel **Default channel for new cost alerts**, under **Settings > Integrations > Slack**, is the channel a new alert starts with. LevelFour posts a short confirmation in the channel when you choose it. It posts nothing else by itself: changing it moves no existing alert, and **Clear default** starts new alerts with no channel. Admins and members can choose it; viewers see which channel it is. ### Investigate an alert Click **Investigate** on an alert and the bot explains it in the alert's thread: what it observed, what caused it and what you could do. Whoever clicks first starts it, and anyone who clicks after that sees it is already under way. You can also reply under an alert, in the channel or the direct message, and the bot answers about that alert. Without the message-reading permissions, mention `@levelfour` in the reply. ## What LevelFour keeps LevelFour keeps a Slack conversation for 90 days after its last message, so a follow-up next week still has its context, and then deletes it. Diagnostics for each answer record timings and token counts, not the conversation. A **Not right** vote sends the LevelFour team the question and the answer. **Tell us what was wrong**, and any request the bot passes on, send the team that person's own words. [Security](/account/security) covers how LevelFour keeps the rest of your data. ## Removing access Click **Disconnect** on the Slack panel under **Settings > Integrations**. Cost alerts stop posting to Slack too. To remove the app from Slack as well, open your workspace's **Manage apps** page and remove LevelFour there. To take the bot out of a single channel, run `/remove @levelfour` in it. ## Troubleshooting | Symptom | What to do | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | The panel waits for authorization | The Slack tab was closed before you approved. Click **Try again** | | The bot does not answer a mention | Check the bot is in the channel with `/invite @levelfour`, then run `/levelfour help` to see whether your Slack account is linked | | The bot answers follow-ups only when mentioned | The workspace declined the optional thread-reading permissions. Reconnect and approve them | | The bot says your Slack email does not match | Ask a LevelFour admin to invite that address, or sign in to LevelFour with the email your Slack account uses | | Slack says the app needs admin approval | Your workspace restricts app installs. Ask a Slack admin to approve the request | | The Slack switch for cost alerts says your email is not in the workspace | Your LevelFour email has no account in this Slack workspace. Sign in to LevelFour with the email your Slack account uses, or ask a Slack admin to add it | | The channel you want is missing from the list | A private channel appears once `@levelfour` is in it. Run `/invite @levelfour` there, then open the list again | | The bot does not answer a direct message | The workspace declined the optional message-reading permissions. Reconnect and approve them | | There are no suggested prompts and no stop button | The workspace declined the optional agent permission, or is on a free Slack plan. Reconnect and approve it | | **Investigate** says the alert is already being investigated | Someone clicked it first. The answer is in the alert's thread | | The bot says it is still answering the previous question | That thread already has a follow-up waiting. Ask again once the answer arrives | | **Tell us what was wrong** says the form did not open | Slack allows a few seconds to open it. Click the button again | ## Next * [Cost alerts](/cost-alerts) covers the rules that post here * [Integrations](/integrations) lists the other tools you can connect * [Slack billing](/connectors/slack-billing) is separate, and it reads your Slack contract amount --- # Jira Source: https://docs.levelfour.ai/integrations/jira.md LevelFour connects to Jira over OAuth. From a savings recommendation you file a Jira issue to track the work, so the change is scheduled and owned before anyone touches production. Jira is on every plan. ## Prerequisites * An Atlassian account with access to the sites and projects you want issues in. * Your Atlassian organization allows third-party apps. Some sites require an admin to approve one. ## Connect ### Open Settings, then Integrations In the dashboard sidebar, click **Settings**, then **Integrations**, then the **Jira** tile. ### Authorize in Jira Click **Connect Jira**. Jira opens in a new tab and asks you to approve access for your Atlassian sites. Approve it. ### Come back to the dashboard > **Success** > > The panel reads **Connected** with your site name. You can now create an issue from a savings recommendation. ## Choosing where issues go Set the project and issue type on the Jira panel. Those are the defaults every issue uses. ## Removing access Click **Disconnect** on the Jira panel. Issues already created stay in Jira. ## Troubleshooting | Symptom | What to do | | --------------------------------------------- | ----------------------------------------------------------------------------------------- | | The panel waits for you to authorize | The Jira tab was closed before you approved. Click **Try again** | | Your site is missing from the approval screen | You do not have access to it, or an Atlassian admin has to approve third-party apps first | | Creating an issue fails after connecting | Check that your Atlassian account can create issues in the chosen project and issue type | ## Next * [Integrations](/integrations) lists the other tools you can connect --- # Boards Source: https://docs.levelfour.ai/boards.md Boards are the first thing you see when you sign in. Each board is a grid of cards: spend so far this month, cost trends, cost by team, open savings, anomalies and more. Every board in your organization is listed under **Boards** in the dashboard sidebar, and as tabs across the top of the page. > **Note** > > Boards are part of the Cost Governance module. Not in your sidebar? Talk to your LevelFour contact. ## The boards you start with | Board | When it appears | What it holds | | ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **FinOps Board** | From the start | KPIs, cost trends, allocation and anomalies. The weekly review board | | **Google Cloud** | Once you connect Google Cloud | Credits, projects, GKE, BigQuery, commitments, budgets and inventory | | **DigitalOcean** | Once you connect DigitalOcean | SKUs, projects, resources, invoices, tags, utilization and inventory | | **Anthropic** | Once you connect Anthropic | The bill by model, workspace, token type and API key, and what Claude Code reports: usage by person and model, activity, tools, approvals, response times, output and versions. [Anthropic](/connectors/anthropic#the-anthropic-board) lists every card | A provider board shows only that provider's cost, and its **Provider** filter is locked to it. It stays in your list while the provider is connected. An organization that connects Google Cloud, DigitalOcean or Anthropic but not AWS sees its provider board in place of the FinOps Board, which is built around AWS. > **Success** > > A card appears once there is data behind it. A newly connected provider fills its board in as its first cost data arrives, usually within a day, instead of showing empty cards. ## Create a board ### Start a new board Click **New board** in the board header. ### Name it and pick a template Type a name, such as `Q1 Cloud Review`. Under **Start from a template**, pick **Blank board** for an empty canvas, or start from the **FinOps Board**, **Google Cloud** or **DigitalOcean** layout. ### Create it Click **Create board**. The new board opens as its own tab. ## Arrange the cards Click **Customize Board** to open the catalog of insights. Filter it by **KPI**, **Cost trends**, **Allocation** or **Operations**, and click an insight to add it to the board. Drag a card to move it. Some cards can also be resized from their edge. **Undo** steps one layout change back, and so does `Cmd+Z` (`Ctrl+Z` on Windows and Linux). **Reset layout** returns the board to its default layout. ## Filter a board The filter bar sits above the cards. The **Provider** chip narrows every card on the board to the providers you pick. On a provider board it is locked to that provider. The date control on the right sets the period. ### Google Cloud credits A Google Cloud bill can carry large credits. The Google Cloud board adds a **Cost basis** toggle to its filter bar: * **After credits**, the default, shows what you pay. * **Before credits** shows gross cost, before any credit applies. The board keeps the choice you make. [Google Cloud credits](/resources/costs#google-cloud-credits) describes the two figures in the API. ## Act on an anomaly The **Anomalies** card lists detected spikes with their status, the size of the spike, the cost impact and how long ago it started. From a row's actions you can: | Action | What it records | | ------------------- | -------------------------------------------------------------------------------- | | **Acknowledge** | A real anomaly you are looking into | | **Mark as planned** | Expected spend, such as a launch or a migration | | **Snooze 1h** | Hide it for an hour. It comes back if the spike continues | | **Not an anomaly** | A false positive. It moves out of the list unless you turn on **Show dismissed** | ## Rename, duplicate or delete a board Open the menu on the board's tab and pick **Rename**, **Duplicate** or **Delete board**. Deleting removes the board's layout and filters. The cards themselves stay available in the catalog. Your last board cannot be deleted. ## Who can change a board Everyone in the organization can read boards. Creating, arranging and deleting a board, and acting on an anomaly, take the **Member** or **Admin** role. [Members and roles](/account/members-and-roles) lists the rest. ## Next * [Costs and Usage](/cost-and-usage) breaks one provider's spend down by service, account, tag or virtual tag * [Virtual tags](/virtual-tags) give cards such as **Cost by Team** one clean key to group by * [FinOps Agent](/finops-agent) answers questions about the same numbers in plain language --- # Costs and Usage Source: https://docs.levelfour.ai/cost-and-usage.md **Costs and Usage** in the dashboard sidebar lists every provider you have connected. Each one opens its own page, from the top down: a row of totals, a chart of spend over time, a cost breakdown table, and, where LevelFour keeps an inventory, the resources behind the cost. A provider shows here once it is connected. [Connect Providers](/connectors) covers each one. ## The totals The strip at the top shows the provider's figures at a glance: | Figure | What it is | | -------------------------- | -------------------------------------------- | | **Total Spend** | Month to date for this provider | | **Forecast · EoM** | Where the month is heading | | **Savings Recommendation** | The open savings on this provider | | **Savings Audit** | The savings captured on this provider so far | Google Cloud shows **Gross cost**, **Credits** and **Net cost** instead, all month to date, so you can see how much your credits cover. ## Spend over time **Spend over time** charts daily cost. Switch between **Daily** and **Monthly** bars, pick a period with the date control, and pick what the bars are split by. On AWS and Google Cloud you can also filter the chart to one tag value. ## Cost breakdown **Cost Breakdown** is a table of the same spend, with the period total in its heading. Group it by: | Group | Groups by | | ----------------- | --------------------------------------- | | **Service** | The provider's service | | **Account** | The account the cost landed in | | **Region** | Where the resource runs | | **Tag** | A provider tag you pick | | **Virtual Tag** | A [virtual tag](/virtual-tags) you pick | | **Resource** | The individual resource | | **Usage Type** | The provider's usage type | | **Cost Category** | Your AWS cost categories | | **Purchase Type** | How the capacity was bought | | **Instance Type** | The instance size | Grouped by a virtual tag, cost that no rule claims appears as its own **Unallocated** row, so the rows still add up to the bill. ### Google Cloud Google Cloud names these the way its console does: **Project**, **Label**, **SKU**, **Pricing Model** and **Machine Type**. Cost categories do not exist on Google Cloud. The chart can also split by **Folder**, **Credit Type** and **Charge Type**. The Google Cloud page adds **Google Cloud insights**: credits, commitments, labels, usage and inventory. A toggle in its header switches its figures between **After credits**, the default, and **Before credits**. [Boards](/boards#google-cloud-credits) explains the two figures. ## Resources For providers where LevelFour keeps an inventory, such as DigitalOcean, a **Resources** table lists what runs in the account: name, type, region, size, project, tags and status. Search by name or ID, filter by type, region, project or tag, and switch between **Active** and **Removed** resources. | Column | What it is | | ----------------------- | -------------------------------------------------------------------------- | | **List price / mo** | An estimate from the list price of that size, before discounts and credits | | **Invoiced MTD** | Invoice line items for the resource this month, before tax and credits | | **Invoiced last month** | The same for last month | Charges that belong to no single resource, such as bandwidth, are totalled under the table. ## Next * [Boards](/boards) put the same numbers side by side across providers * [Virtual tags](/virtual-tags) create the keys the **Virtual Tag** grouping reads * [Costs](/resources/costs) is the same data through the API --- # Savings Recommendations Source: https://docs.levelfour.ai/savings-recommendations.md Open **Savings** in the dashboard sidebar. The page lists every savings recommendation for a connected provider, with what it saves a month and where it stands. ## Statuses Each tab lists one status and shows its count. | Tab | What it lists | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **All** | Every recommendation. This tab also shows the overview strip: **Total Pipeline**, **Top Opportunities**, and **Top Savers** once anyone has applied a saving | | **Available** | Savings found and not approved yet. The row action is **Save now** | | **Pending** | Savings approved and not applied yet. The Implementation column follows the rollout | | **Needs Approval** | Savings a member asked an admin to approve. Only people who can approve see this tab | | **Saved** | Savings already applied. Each one also appears in [Savings Audit](/savings-audit) | | **Rejected** | Savings someone rejected, with the reason they gave | | **Unavailable** | Savings that cannot be applied right now. **Total Pipeline** leaves these out | A **New** chip marks a recommendation created in the last seven days. An **Updated** chip marks one that changed since it was created. ## Find what you need * **Search services** narrows the table by service name. * The filter takes **Savings** (show only parents or only groups) and **Implementation Method** (**Automated**, **IaC** or **Manual**). * **Columns** shows, hides and reorders columns. * The row menu holds **Remind me** (tomorrow, next week, in two weeks or next month), **Copy link** and **Pin for attention**. A banner at the top of the page tells you when reminders are due. ## Linked and grouped savings Some savings change what another one is worth. A **Linked** badge marks them: the **Parent** is the one to apply first, and each dependent reads **Child of** with the parent's ID. Applying a child before its parent opens a **Linked Savings Dependency** prompt that names the saving you would give up by going out of order. A **Group** row gathers several savings of the same kind. Expand it to see each one. ## Open a recommendation Select a row, or **Save now**, to open the drawer. It has three steps: | Step | What it holds | | ------------------ | ------------------------------------------------------------------------------------------------------------------- | | **Recommendation** | What changes and why, the resources it covers, the risk, and the decision | | **Implementation** | What the chosen [implementation method](/savings-recommendations/implementation-methods) needs before it can run | | **Status & Logs** | The rollout, step by step. See [Approvals and rollout](/savings-recommendations/approvals-and-rollout#status--logs) | The **Recommendation** step starts with the title, a description, and **Open Resource in AWS Console**. Below them sit the service, account, region, environment and tags, then two tabs: **Change** (what will change, current against new) and **Metrics**. Further down are **Resources in this recommendation**, **Risk assessment**, **What happens during implementation**, and the **Activity** timeline. The panel on the right shows the potential saving, the **Implementation method** list, and the action for your role. A notice at the foot of the drawer explains how savings on the resource are attributed under your LevelFour agreement. ## Accept or reject An admin picks an implementation method and selects **Approve Savings**. [Approvals and rollout](/savings-recommendations/approvals-and-rollout) covers what happens next, and how a member asks an admin to approve instead. To turn a saving down, select **Reject Savings** and give a reason: * Operational or availability risk * Strategic decision or internal priority * Savings not applicable to the environment * Other **Can you tell us why?** takes an optional explanation. **Reject and Close** moves the saving to **Rejected**. An admin can still approve it later from the same drawer. > **Note** > > Viewers see every recommendation but cannot approve, reject or request approval. [Members and roles](/account/members-and-roles) covers who can do what. ## Choose which resources it covers A saving that spans several resources lists them under **Resources in this recommendation**, each with the date it was analyzed, its cost and its saving. Clear the ones you want to keep out, then select **Save changes**. The resources you cleared leave this saving, and the saving and cost are worked out again when it is applied. You can change the selection while the saving is still available. Once someone requests approval, approves it, or it starts running, the list is read-only. ## Activity The **Activity** timeline records everything that happens to the saving: approvals and rejections, the method someone chose, approval requests, access grants, pull requests, and whether the change applied or failed. It is also where the team talks about it. A comment takes Markdown, `@` mentions a teammate, and accepts PNG, JPEG, GIF, WebP or PDF attachments up to 10 MB. **Seen by** lists the teammates who opened the saving. **Programmatic access** counts reads by API keys and connected apps, such as the MCP server, the FinOps Agent, the CLI or the SDKs. ## Create a task **Create a task** in the drawer files the saving as an issue in a connected Jira project. Pick the site and project, confirm the title, and the link to the issue lands in the timeline. [Jira](/integrations/jira) covers connecting it. ## Next * [Implementation methods](/savings-recommendations/implementation-methods) is how an approved saving reaches your cloud * [Approvals and rollout](/savings-recommendations/approvals-and-rollout) is who approves, what runs, and how to retry * [`l4 recommendations`](/cli/commands/recommendations) reads and decides savings from a terminal --- # Implementation methods Source: https://docs.levelfour.ai/savings-recommendations/implementation-methods.md Every saving runs through one implementation method. You pick it from the **Implementation method** list in the recommendation drawer, before you approve. The drawer's **Implementation** step then shows what that method still needs. | Method | What happens | What it needs | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **Automated Savings** | LevelFour applies the change in your cloud account | A connected access grant for this saving | | **IaC Pull Request** | LevelFour opens a pull request with the code change, and you merge it | A connected GitHub or GitLab organization and at least one repository | | **Automated Savings + IaC Pull Request** | LevelFour applies the change, then opens a pull request with the matching code so your IaC state does not drift | Both of the above | | **Manual Setup** | You apply the change yourself from step-by-step instructions | Nothing from LevelFour | **Approve Savings** stays disabled until the chosen method has what it needs. > **Note** > > Once a saving is approved, its method is locked. The one exception is a failed run, which re-opens the choice so you can [retry](/savings-recommendations/approvals-and-rollout#when-a-run-fails) with the same method or another. ## Automated Savings Automated Savings acts through a grant: scoped, time-limited access that covers exactly the actions and resources of one saving, and expires on its own. ### Choose Automated Savings In the drawer's **Implementation method** list, pick **Automated Savings**. ### Authorize in AWS Select **Authorize in AWS**. CloudFormation opens in your AWS account with a stack that attaches this saving's permissions to the `LevelFourSavingsExec` role. The panel states how many actions and resources the grant covers. Create the stack. ### Wait for Access granted The panel reads **Waiting for approval** until AWS reports the stack back to LevelFour, then **Access granted**. [Approvals and rollout](/savings-recommendations/approvals-and-rollout#access-grants) lists every state a grant can be in. ### Approve **Approve Savings** unlocks once access is granted. To apply the permissions yourself instead of launching the stack, expand **Set up manually**. It shows the IAM role and the IAM policy JSON for this saving. > **Note** > > An account onboarded before the `LevelFourSavingsExec` role existed shows **One-time setup required**. Create the role once with [Automated Savings setup](/connectors/aws/automated-savings), then run the action again. ## IaC Pull Request ### Choose the organization Pick **IaC Pull Request**. Under **Version control and organization**, choose a connected GitHub or GitLab organization, or select **Connect organization** to add one. [GitHub](/integrations/github) and [GitLab](/integrations/gitlab) cover connecting each. ### Choose the repositories Under **Repositories**, pick the repositories that define this resource. **Repository missing? Manage access** opens that organization's connection page in the dashboard, where you give LevelFour access to another repository. After approval, LevelFour opens the pull request in the repositories you chose. IaC Pull Request edits Terraform code. ## Automated Savings + IaC Pull Request This method applies the change in your cloud first, then opens a pull request with the matching code, so the next plan of your infrastructure code does not undo it. It needs a connected grant and at least one repository before it can be approved. Follow both sections above. ## Manual Setup The **Implementation** step shows the instructions for this saving, with the commands to run in your own cloud console. When you have applied the change, open the row menu on the **Savings** page and select **Mark as completed**. The saving moves to **Saved** and appears in [Savings Audit](/savings-audit). ## From other surfaces * The MCP server records a method with `set-implementation-method` and mints a grant with `create-savings-grant`. See [Tools](/mcp/tools) * [`l4 rec execute`](/cli/commands/recommendations/execute) requests execution with a method from a terminal --- # Approvals and rollout Source: https://docs.levelfour.ai/savings-recommendations/approvals-and-rollout.md ## Who can do what | Role | On a savings recommendation | | ------ | --------------------------------------------------------------------- | | Viewer | Reads everything. Cannot approve, reject, or request approval | | Member | Picks a method and asks an admin to approve with **Request approval** | | Admin | Approves or rejects, and answers members' requests | [Members and roles](/account/members-and-roles) covers how roles are assigned. ## Two-person approval A member can prepare a saving without being able to run it. An admin makes the call. 1. The member opens the saving, picks an [implementation method](/savings-recommendations/implementation-methods), and selects **Request approval**. The button then reads **Pending admin approval**. 2. The saving moves to the **Needs Approval** tab, which only admins see. The dot on that tab pulses while requests are waiting. 3. The admin opens it. **Requested by** names the member, and their method is already selected: approve to keep it, or pick another. 4. The admin selects **Approve Savings** or **Reject Savings**. After an approval, the drawer offers **Copy link** so the admin can hand the saving back to whoever set it up. ## After approval An approved saving moves to **Pending** until it runs. The Implementation column on the **Savings** page follows it: **In Progress**, then **Completed** or **Failed**. Execution can take 12 to 72 hours to begin, and the page updates on its own when it does. Once the change is applied, the saving moves to **Saved** and appears in [Savings Audit](/savings-audit). > **Note** > > On an evaluation plan, a banner shows how many executions and days you have left. ## Access grants [Automated Savings](/savings-recommendations/implementation-methods#automated-savings) acts through a grant: access scoped to one saving's actions and resources. The drawer shows where the grant stands. | The drawer shows | Meaning | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | **Grant scoped, time-limited access that expires automatically.** | No grant yet. Select **Authorize in AWS** to create one | | **Waiting for approval** | The stack is launched. LevelFour is waiting for AWS to report it and is checking the access | | **Access granted** | The grant is live. **Approve Savings** unlocks | | **Grant failed** | The stack or the check failed. Authorize again | | **Grant expired** | The grant ran out before the saving did. Authorize again | A grant for a saving expires three days after it is created. A grant for a [commitment renewal](/commitments/renewals) lasts 33 days, long enough to reach the end of the term it renews. The **Activity** timeline records each grant: who requested the temporary access role, and whether it connected. ## Status & Logs The drawer's third step follows the run. | Field | What it shows | | ------------------------- | ---------------------------------------- | | **Triggered Via** | The implementation method | | **Timestamp** | When the run was triggered | | **Implementation Status** | Where the run stands | | **Approved by** | The admin who approved it | | **Execution Logs** | Each step of the run and what it printed | Until the run starts, the step reads **Execution hasn't started yet**. ## When a run fails The drawer shows **Execution failed** with "A step did not complete." Open **Status & Logs** to see which step failed and why. The implementation method list opens again. Keep the method or pick another, complete what it needs, and select **Retry**. ## From other surfaces * The MCP server queues a rollout with `start-rollout`, lists waiting requests with `list-approval-requests`, answers one with `release-rollout`, and reads a step's output with `get-execution-logs`. See [Tools](/mcp/tools) * [`l4 rec execute`](/cli/commands/recommendations/execute) requests execution from a terminal --- # Commitments Source: https://docs.levelfour.ai/commitments.md Open **Commitments** in the dashboard sidebar, then **Reserved Instances** or **Savings Plans**. Both pages read the commitments on your connected AWS accounts. Other providers show as **Soon** in the provider picker. > **Note** > > Utilization for a single commitment comes from its own lines in your [detailed cost data](/connectors/aws/detailed-cost-data). Without it, that commitment's metrics read **Not measured yet**. ## The summary strip | Card | What it shows | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Reservations** or **Plans** | How many you hold, across how many services or plan types, and what they commit you to a month | | **Saved vs on-demand** | What your commitments save a month against on-demand prices, and how far below on-demand that puts you | | **Unused** | Capacity you pay for and do not use, a month, beside the utilization rate. At full utilization it reads **saturated** | | **Expiring in 30 days** | How many end within 30 days. **View** filters the table to them | ## Services and plan types On **Reserved Instances**, a tab per service narrows the page to EC2, RDS, ElastiCache, Redshift, OpenSearch, Elasticsearch (legacy) or MemoryDB. A tab notes when something on that service expires within 30 days. ## Utilization and coverage **Utilization** is how much of the capacity you bought is in use. **Coverage** is how much of the eligible usage a commitment pays for. The page shows both per service for reservations, and per plan type for Savings Plans, which AWS measures as one utilization rate rather than separate CPU and memory figures. **Utilization over time** charts the daily figures over one, three, six or twelve months. ![Utilization over time for Amazon RDS reservations, showing a utilization line near 90 percent and a coverage line near 70 percent over a month](/screenshots/commitments-utilization-light.png) Utilization and coverage are measured across every service, so they stay the same when you pick a service tab. ## The table * The date control keeps the commitments that were in force at any point in the window you pick. * **Grouped by** groups rows by service or plan type, account, region, status or payment option. * **Columns** shows, hides and reorders columns. * **More filters** narrows by account, status, payment option, region, term, tag, service and the attributes each service adds, such as engine, offering class or instance family. Each row carries a status: | Status | Meaning | | ------------------- | ---------------------------------------------------------- | | **Active** | Running | | **Expiring** | Ends within 30 days | | **Queued** | Bought, and starts on a later date | | **Payment pending** | Waiting on the payment AWS takes at purchase | | **Payment failed** | The payment failed, so the commitment never ran | | **Retired** | No longer running, usually because its term ended | | **Returned** | A Savings Plan handed back to AWS within its return window | ## Commitment detail Select a row to open the commitment. The header lists its service, instance class, quantity, account, region, rate, payment option and dates, with **Open in AWS Console**. Two panels sit below it: * **Renewal** prices what replacing it would cost, while a renewal can still be placed. See [Renewing a commitment](/commitments/renewals). At any other time this panel is **Contract**: the term, what is left of it, and the pricing, with **Compare with on demand** * **Metrics** charts this commitment's utilization, and the coverage of all your reservations on its service The **Status & Logs** tab follows a renewal's purchase once one is approved. ## Expiring reservations When reservations are about to end, a bar at the top of **Reserved Instances** names them, with **Review** to open the commitment it names. The **Reserved Instances** entry in the sidebar carries a count of the ones expiring within 30 days. ## From other surfaces * The MCP server reads the same position through `get-commitment-overview`, `list-commitments`, `get-commitment-utilization` and `get-commitment-coverage`, and your effective savings rate through `get-commitment-savings-rate`. See [Tools](/mcp/tools) * [`l4 commitments`](/cli/commands/commitments) reads coverage, utilization and what expires from a terminal --- # Renewing a commitment Source: https://docs.levelfour.ai/commitments/renewals.md A renewal replaces a commitment that is about to end. You price the options in the commitment's **Renewal** panel, pick one, and approve it. Nothing is bought until an admin approves. > **Warning** > > An approved renewal buys a commitment that cannot be cancelled, modified or refunded once it starts. Read the terms in **What you are buying** before you approve. Only organization admins can renew. Everyone else sees the prices with the line **Only organization admins can renew a commitment.** ## When you can renew Open the commitment from **Commitments** in the sidebar. What the **Renewal** panel offers depends on how close the commitment is to its end. | When | What the panel does | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | More than 30 days before the end | Shows the date the renewal opens | | In the last 30 days | Schedules the renewal for the moment the current term ends, so coverage continues without a gap. Confirm at least 3 hours before the end, or 4 when only the end date is known | | Less time than that | Buys immediately. A short uncovered gap at the term boundary is possible | | Up to 14 days after the end | Buys immediately. The new term starts now, and the hours without coverage are not recovered | | After that | The renewal window has closed. New coverage comes from a new recommendation | A commitment that was returned, exchanged, cancelled, or never ran because its payment failed has nothing to renew, and the panel says which of these happened. ## Compare the options ![The Renewal panel for an RDS Reserved Instance, comparing the current contract of 18 units with a recommendation of 15, beside the What you are buying box and the implementation method list](/screenshots/commitments-renewal-light.png) The **Contract** table sets the commitment as it stands beside the option you pick, row by row: **Quantity**, **Term**, **Payment option**, **Paid at purchase**, **Charged hourly**, **Reserved and unused**, **Left on demand**, and **All in for the term**, which adds the cost of any usage the option would leave on demand. **Plan** in **What you are buying** chooses the option: | Plan | What it buys | | ------------------ | ----------------------------------------------------------------------------------------------------------- | | **Recommendation** | The quantity LevelFour sized from how much of the commitment you used. It is never more than you hold today | | **Renew as is** | The same terms, bought again | | **Custom** | Your own quantity, term and payment option | **Quantity** steps up and down with **-** and **+**. **Term** and **Payment option** list only what AWS sells for this commitment. The price at the top of the box, **cost for the term**, shows the difference against renewing as is. **Why these numbers** explains the recommended quantity, and **See the usage behind this** charts the daily usage it was sized on. The panel also names the AWS region and the date the prices were read. > **Note** > > If you have already accepted a saving that removes capacity on the same service, the panel lists it. Let that change land first, or the renewal reserves capacity you are about to give up. ## Approve the renewal ### Choose the plan and terms Pick the **Plan**, then adjust **Quantity**, **Term** and **Payment option** if you need to. ### Choose an implementation method Pick how the purchase is delivered, from the same list as any saving. [Implementation methods](/savings-recommendations/implementation-methods) covers what each one needs. An access grant for a renewal lasts 33 days, so it is still live when the term ends. ### Acknowledge Tick **I understand this places a commitment that cannot be cancelled, modified or refunded once it starts.** ### Approve The button reads **Approve and schedule** for the recommendation. For the other plans it reads **Schedule renewal**, or **Renew commitment** when the renewal buys immediately. After approval the panel confirms when the renewal will be placed, and **View status and logs** opens the purchase. ## Follow the purchase The commitment's **Status & Logs** tab shows **Purchase status** in five steps: 1. Verify access and approval 2. Resolve offering 3. Price check 4. Purchase 5. Confirm activation Some services offer no way to check a price before buying, so the price check is marked as not applicable for them. **Execution logs** lists what each step ran. > **Error** > > If the purchase call returns no clear answer, the status says so: it is not yet known whether money moved. The next step is to reconcile with AWS, never a second purchase. AWS lets you return a Savings Plan for seven days after it is bought. While that window is open, the plan's **Renewal** panel shows **Returnable until** with the date. ## From other surfaces * The MCP server prices the options with `get-commitment-renewal-options` and sizes the repurchase with `get-commitment-renewal-plan`. See [Tools](/mcp/tools) * [`l4 commitments renewal`](/cli/commands/commitments/renewal) sizes the repurchase from a terminal --- # Virtual tags Source: https://docs.levelfour.ai/virtual-tags.md A virtual tag is a tag key LevelFour assigns to your cost from rules you write, alongside the tags your providers report. Use one to fix tags that disagree, to map accounts and services to owners, or to split a shared cost. [Virtual tags](/guides/virtual-tags) in the guides explains how LevelFour picks a value and walks through the common patterns. This page covers the editor in the dashboard. To keep tags in YAML files instead, see [`l4 tags`](/cli/commands/tags). ## Find your tags Open **Settings**, then **Tags**. The page lists every tag key your organization has: * **All tags**, **Virtual tags** or **Provider tags**, from the tabs at the top * the key, its description, the providers it comes from, how many values and resources it has, and the spend it covers The bar above the list compares **Tagged spend** with **Untagged spend**, across provider and virtual tags together. ## Create a virtual tag ### Start a new tag Click **Create new tag**. ### Name it Under **Tag key**, type the name reports group by, such as `team`. It cannot repeat another virtual tag, and it cannot start with `vtk_` or `ptk_`. Add a **Description** so the next person knows what it is for. If the key matches one a provider already reports, the virtual tag extends that key. **Can Override** then decides who wins on a cost that carries both: turned on, your configurations decide the value; turned off, the provider's value stays wherever it exists. ### Fold in provider keys, if they mean the same thing Under **Collapsed keys**, add provider keys that are spellings of the same idea, such as `env` and `Environment`. They are read in order, before the configurations, and the first one present on a cost supplies its value. ### Add configurations Click **Add configuration**. Each configuration has three parts: * **Assign** decides what the matched cost becomes: **One value**, **Split by percent** across several values totalling 100%, or **Split by cost**, which divides it the way an existing tag key already does. * **Match costs** holds one or more rules. A rule names a provider and conditions such as account, region, service, tag or label. Cost matches when it matches any rule. * **Time frame**, optional, limits the configuration to cost billed between two dates. Order sets priority: the first configuration that matches a cost wins. Drag a configuration to move it. ### Preview, then save Click **Preview** to see what the tag would claim over a recent window: spend and resources per value, plus what stays **Unallocated** and what is **Shadowed** by a configuration higher up. Adjust until the unallocated share is what you expect, then click **Save tag**. > **Note** > > To apply a tag to cost that is already billed, turn on **Backfill past cost** and pick the month to start from. LevelFour reprocesses those months in the background, and the tag's page shows progress until the figures update. ## Edit a virtual tag Open the tag from the list. Its page shows the values it assigns, its collapsed keys, its configurations in priority order, and the resources that carry it. * **Edit tag** changes the key name and description. * Each configuration can be edited, reordered or deleted. Cost a deleted configuration matched falls through to the next one once the tag is reprocessed. * A value can be renamed from the **Values** list. * **Delete key** removes the tag and its configurations, and reports lose it. This cannot be undone. If someone else changes the same tag while you are editing, your save is refused and the page shows their version. Check it and make your change again. Neither edit silently overwrites the other. Provider tags open read only. They are managed at the provider. ## Where virtual tags show up A saved virtual tag works where a provider tag does. [Costs and Usage](/cost-and-usage) groups its breakdown by it under **Virtual Tag**, and cards such as **Cost by Team** on [Boards](/boards) can group by it. The API, the CLI and the MCP server read it too. The guide's [Where virtual keys appear](/guides/virtual-tags#where-virtual-keys-appear) lists every place. ## Who can change tags Everyone in the organization can read tags. Creating, editing and deleting a virtual tag takes the **Member** or **Admin** role. ## Next * [Virtual tags guide](/guides/virtual-tags) has the rule patterns: splitting a shared account, backfilling, nesting one tag in another * [`l4 tags`](/cli/commands/tags) keeps the same tags in files you apply from a terminal * [Costs and Usage](/cost-and-usage) is where you read cost grouped by a tag --- # Savings Audit Source: https://docs.levelfour.ai/savings-audit.md Open **Savings Audit** in the dashboard sidebar. It holds one table, **Savings implemented**, with a row for every saving applied so far, across every provider. The line under the title, **Total saved**, is the monthly saving of the rows the table lists. It follows your filters and the date window, so the figure and the rows always agree. ## What each row shows * **Savings ID**, which opens the recommendation it came from on the **Savings** page * The provider, account and service it applies to * When it was implemented * The usage type, amount and unit it changed * What it saves a month * The implementation method it ran through * Who requested it and who approved it **Columns** shows, hides and reorders these. ## Narrow the table * **Search savings** matches the savings ID, provider, account, service, usage type, implementation method, requester and approver. * **Filter by** limits the table to one provider. It appears once more than one provider has savings. * **More filters** narrows by provider, requester, approver, service and implementation method. * The date control beside the title limits the table to savings that were saving money inside the window you pick. The **Total saved** line then names that window. ## Export and billing **Download CSV** exports the rows the table shows, with the window they cover. **Access Billing** opens the Billing page. It does not appear during an evaluation. > **Note** > > A saving reaches this page once it is applied. Approved savings that have not run yet stay under **Pending** on the Savings page. [Approvals and rollout](/savings-recommendations/approvals-and-rollout#after-approval) covers that stage. ## From other surfaces * The MCP server returns realized savings, with who approved them, through `get-savings-realized`. See [Tools](/mcp/tools) * [Savings](/resources/savings) is the same data through the SDKs and the REST API --- # Billing Source: https://docs.levelfour.ai/billing.md LevelFour charges a success fee on the savings it realizes for you. The fee is billed and collected by AWS Marketplace, so it arrives on your AWS invoice rather than as a separate bill. Open **Settings**, then **Billing**, in the dashboard sidebar. Every member of the organization can read it. > **Note** > > An organization on an evaluation has nothing billed, so it has no Billing page. ## Your account The top card identifies your billing setup: | Field | What it tells you | | --------------------- | ---------------------------------------------------- | | **Client ID** | The prefix on your savings line items | | **Billing frequency** | Monthly, with the date of the next charge | | **Contract** | Your savings share agreement and the date it started | | **Billing method** | Billed and collected by AWS Marketplace | **View in AWS Marketplace** opens your subscription in the AWS console. ## Charging this month Billing runs in arrears. The period charged this month is an earlier one, so the card names the period it charges and the date it bills. | Figure | What it is | | -------------------- | ------------------------------------------------------ | | **Savings** | Savings realized in the period being charged | | **Success fee** | What LevelFour charges on those savings, with the rate | | **Your net savings** | Savings after the success fee | ## Statements **Statements** lists one row per billing period: the period, the savings, the success fee and its status. The status reads: | Status | Meaning | | ----------------------- | ---------------------------------------------------------------- | | **Invoiced** | The fee is on your AWS invoice. AWS collects it on its own terms | | **Processing** | The charge is on its way to AWS | | **Billing** with a date | The period is closed and bills on that date | | **Scheduled** | The period bills once the periods ahead of it have | | **In progress** | The month is still running. The row says when it closes | The running month shows **Still accruing** in place of its savings, because its total is not known yet. Click **Download** on a row to save that period's statement. The first statement appears after your first billing period closes. ## Next * [Savings Audit](/savings-audit) lists the savings realized behind each period * [Members and roles](/account/members-and-roles) covers who can do what in the organization --- # FinOps Agent Source: https://docs.levelfour.ai/finops-agent.md The FinOps Agent answers questions about your organization's cloud spend in plain language. It reads the same costs, savings and commitments the dashboard shows, for your organization only, and answers with the figures and tables behind them. > **Note** > > The FinOps Agent is a module. Not in your sidebar? Talk to your LevelFour contact. ## Open the agent There are three ways in: * **FinOps Agent** in the dashboard sidebar opens it full screen. * **Ask the agent** in the dashboard header opens it as a side panel next to the page you are on. **Expand to full screen** makes it the whole page, and **Back to the side panel** returns it. * **Ask FinOps Agent** at the top of these docs opens it in the dashboard, in a new tab. Leaving the page keeps the conversation and anything you had half typed. ## Ask a question Type a question the way you would ask a colleague. A new chat suggests a few to start with: * What are we spending this month? * Which services are driving our spend? * What are our top 3 savings opportunities? * How much could we save right now? Opened next to a dashboard page, the agent can also suggest a question about that page. Savings come back as a table you can open: **Open saving** takes you to the saving itself, and **Open in Savings** to the full list. > **Warning** > > The FinOps Agent can make mistakes. Check the numbers that matter against the page they come from before you act on them. ## Choose the answer language The agent answers in **Match what I write** by default, which replies in the language of your question. To pin a language, pick **English**, **Português do Brasil** or **Español** from the language menu in the chat, or under **Settings > General > FinOps Agent**. The setting is yours alone: everyone in the organization sets their own. It changes the answers only. The rest of LevelFour stays in English, and figures stay in the currency your providers bill in. ## Approve or reject a saving from the chat Ask the agent to approve or reject a saving, or to walk through several one at a time, and it shows a decision card rather than acting on its own: ### Read the card The card names the saving, what it is worth each month, and the decision you asked for. For an approval it also asks how the change should be delivered. ### Confirm or cancel Click **Approve** or **Reject** to record it under your name, or cancel to leave the saving as it was. Approving records the decision, and approves the change for rollout if a teammate requested one. Applying the change to your cloud account is a separate step, on the Savings page. Rejecting closes the saving and changes nothing in your cloud account. When a delivery method needs something the chat cannot set up, such as cloud access or a repository, the card says so and sends you to the Savings page to finish. Viewers can ask anything but cannot record a decision. The card tells them an admin records it, and they can raise it on the Savings page. ## Your chats The chat list keeps your conversations, grouped by date. From a chat's options you can **Pin** it, **Rename** it, **Archive** it or **Delete** it. **Delete all chats** clears the list. Your chats are private to you. Nobody else in the organization can open them. ## Rate an answer or pass something on Each answer has a thumbs up and a thumbs down. A thumbs down sends the LevelFour team your question and the answer, so they can see what went wrong. To ask for something LevelFour does not do yet, or to report a problem, ask the agent to pass it on. It sends the LevelFour team your words and tells you once they have it. ## Next * [Savings](/savings-recommendations) is where a decision made in the chat continues * [Boards](/boards) and [Costs and Usage](/cost-and-usage) show the numbers the agent answers from * [MCP](/mcp) brings the same data into Claude, Cursor and other assistants you already use --- # Cost alerts Source: https://docs.levelfour.ai/cost-alerts.md A cost alert watches one thing, an account or project, or your commitments, and tells the people named on it when a line you set is crossed. Alerts belong to the organization: everyone sees the same list, and a change to an alert applies to everyone on it. ## Create an alert ### Open Cost alerts In the dashboard sidebar, click **Settings**, then **Cost Alerts**, then **Create alert**. ### Choose the type and what it watches Pick one of the three types below, then the source: an account or project for **Budget** and **Cost changes**, **AWS commitments** for **Commitment**. ### Set when it fires The rule reads as a sentence, and you fill in its numbers. The form prefills none of them, so an alert never fires on a value nobody chose. ### Choose who hears it Add the members who should get it. Only members of the organization receive alerts. Then pick the **Slack channels** it posts in, up to 10. A new alert starts with your organization's default channel, if one is set, and you can remove it or add others. The list shows the public channels and the private ones `@levelfour` is in. **Your Slack DMs** turns on direct messages for you, for every cost alert you receive. **Your email** sends each one to the email address on your account. Both stay off until you turn them on, and both are the same switches as the **Cost alert** row on **Notification preferences**. ## Types | Type | Fires when | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Budget** | Spend passes a share of a monthly budget, or an amount, at any point in the month or within a number of days of its start or end. One alert can carry several thresholds. | | **Cost changes** | Spend rises more than an amount you set against the same days last week, the same days last month, or the previous 7 days. A floor keeps a small rise on a small bill quiet. | | **Commitment** | A Reserved Instance or Savings Plan is within a number of days of its end, from 1 to 365. | ### Commitment alerts A commitment alert judges each commitment on its own. Nine commitments ending across two days are nine warnings, one per commitment, each sent once for that term. A commitment you already renewed stays quiet, and LevelFour skips one that has already ended. > **Note** > > Commitment alerts read AWS Reserved Instances and Savings Plans today. ## Where alerts arrive * **The bell** in the dashboard header, for every recipient. * **Each Slack channel on the alert**, one post per channel. * **A Slack direct message**, for each recipient who turned on Slack for cost alerts, on **Settings > Notification preferences** or in an alert's form. * **An email**, for each recipient who turned on email in the same places. [Slack](/integrations/slack) covers the channels, the default channel, direct messages and the **Investigate** button. ## How often alerts are checked LevelFour checks every alert in every organization about every 15 minutes. A crossing has to hold on two checks in a row before the alert fires, so a figure sitting on the line does not flap. Each crossing is announced once per period, so a budget that stays over its line all month tells you once that month. When a figure comes back under its line, the alert's history records it, and nobody is notified. ## History Open an alert to see what it has said, newest first: when it fired or cleared, the figure against the line it crossed, the commitment it was about, and whether anyone was told. A firing that reached nobody says so. ## Edit, pause, delete and suggestions * **Edit** an alert by clicking its name. Every field can change, what it watches included. * **Pause** an alert with the switch on its row. A paused alert shows who paused it, and it moves to the **Paused** tab. * **Delete** an alert with the trash icon on its row, or from its form. LevelFour asks first, because the alert's history goes with it. To stop an alert for a while, pause it instead. * **Suggestions** proposes budgets from an account's own billing history. Turning one on sends it to you. * **Admins and members** create, change and pause alerts. Viewers see them. ## From the API, the terminal and AI clients Every change above also works outside the dashboard, with the same rules and permissions: | Surface | Create | Change | Pause and resume | Delete | | ------------------------------------------- | -------------------------- | -------------------------------- | ------------------------------------- | --------------------------------- | | [REST API and SDKs](/resources/cost-alerts) | `POST /api/v1/cost-alerts` | `PATCH /api/v1/cost-alerts/{id}` | `PATCH` with `enabled` | `DELETE /api/v1/cost-alerts/{id}` | | [CLI](/cli/commands/alerts) | `l4 alerts create` | `l4 alerts update` | `l4 alerts pause`, `l4 alerts resume` | `l4 alerts delete` | | [MCP server](/mcp/tools) | `create-cost-alert` | `update-cost-alert` | `pause-cost-alert` | `delete-cost-alert` | A change sends only the fields it names, and a new rule replaces the whole rule, so raising one threshold means sending the rule back with that figure changed. Writes need a read-write API key, or an AI client connected with write access. An AI client deletes an alert only when it names the alert as well as its id, so the alert it deletes is the one it read. ## Next * [Slack](/integrations/slack) sets up the default channel and direct messages, and explains **Investigate** * [Notifications](/notifications) covers the bell and your notification preferences * [Commitments](/commitments) shows every Reserved Instance and Savings Plan an alert watches --- # Notifications Source: https://docs.levelfour.ai/notifications.md LevelFour notifies you in the dashboard when something needs you or has changed: a teammate mentions you, an approval waits on you, or a saving was applied. The bell in the dashboard header holds them, and clicking one takes you to the page it is about. ## What you are notified about | Notification | When | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | **Mentions and approvals** | Someone mentions you or replies to your thread on a saving, asks you for an approval, or decides one you asked for | | **Account and savings updates** | A saving was applied or failed, a cost data export needs setting up, or a connector's status changed | | **Cost alert** | A [cost alert](/cost-alerts) you receive fires | Notifications are personal. Each person in the organization gets their own and chooses their own preferences. ## Choose what you get Open **Settings**, then **Notification preferences**. The **All notifications** switch at the top turns everything on or off at once. Below it, each notification has its own row, with a switch for **In-App** (the bell), **Email** and **Slack**. A few are always delivered, and those say so on their row. Your choices belong to the organization you are signed in to. If you belong to two organizations, you choose separately in each, and each bell shows only that organization's notifications. ## Email and Slack Any notification can also reach you by email, at the address on your LevelFour account, and as a direct message from LevelFour in your organization's Slack workspace. Both are off for every notification until you turn them on. Open a notification's row and switch on **Email** or **Slack** for the ones you want there. The bell keeps getting them either way. Slack needs your organization to connect a Slack workspace first. Until then, a **Slack** card above the list offers **Connect Slack**, and every **Slack** switch, here and in the bell's own preferences, stays grayed out and cannot be turned on. Once a workspace is connected, LevelFour finds you in it by the email on your LevelFour account, so a direct message reaches you only if your Slack account uses that same email. > **Note** > > If the page says notifications are not switched on for your workspace yet, talk to your LevelFour contact. ## Cost alerts Every cost alert you receive is in the bell. The **Cost alert** row sends it to Slack and email too, and an alert's form has the same two switches, under **Your Slack DMs** and **Your email**. A cost alert also posts in the Slack channels the alert names, whatever your own switches say. [Slack](/integrations/slack) covers direct messages and the channels each alert posts in. ## Next * [Members and roles](/account/members-and-roles) decides who can approve a rollout * [Savings](/savings-recommendations) is where most notifications lead --- # Guides Source: https://docs.levelfour.ai/guides.md Most of these guides connect LevelFour to something you already run, from a CI pipeline to your own CLI to a chat channel. The PR bot and the comment box on a savings recommendation are the other kind, surfaces LevelFour runs for you. > **Note** > > Most of the code on these pages needs a LevelFour API key before it runs. [Authentication](/authentication) is where keys come from, and [Key scopes](/authentication#key-scopes) is the read versus read-write split. The Slack and Google Chat receivers take the webhook signing secret instead, which [Webhooks](/webhooks) owns. ## The guides * [Formatting comments](/guides/formatting-comments) is the Markdown the comment box on a savings recommendation understands, and the GitHub syntax it does not * [Filtering and sorting](/guides/filtering-and-sorting) narrows a cost or recommendation query from the terminal, with the same parameters in the SDKs * [Virtual tags](/guides/virtual-tags) assigns one tag key to every AWS and GCP cost from rules you write, to fix inconsistent tags and split shared costs * [GitHub Actions](/guides/github-actions) has whole workflow files that comment a cost summary on a pull request and upload a daily report * [PR bot configuration](/guides/pr-bot-configuration) tunes the bot LevelFour runs on your pull requests, through the optional `.levelfour/config.yml` * [CI/CD Integration](/guides/ci-cd) gates a pull request on cloud cost with `l4 estimate` and `l4 diff`, in GitHub Actions or GitLab CI * [Integrate the SDK into your CLI](/guides/integrate-sdk-into-your-cli) embeds the SDK as a library in a CLI your team already ships, for typed objects and language-native errors * [Integrate l4 into your CLI](/guides/integrate-l4-into-your-cli) shells out to the `l4` binary from scripts, Makefiles and CI runners, with no SDK in the dependency tree * [Backstage Plugin](/guides/backstage-plugin) builds both halves of a plugin that shows LevelFour cost data in your developer portal * [Slack Integration](/guides/slack-integration) verifies the signature on a LevelFour webhook and posts the event into a Slack channel * [Google Chat Integration](/guides/google-chat-integration) does the same for a Google Chat space * [Scheduled Execution](/guides/scheduled-execution) runs an unattended check on cron or a workflow, and says when webhooks beat a schedule ## Next * [CLI](/cli) is the `l4` binary several of these call: the command groups, the global flags and the exit codes to script against * [SDKs](/sdks) is the Python, TypeScript and Go clients the code samples are built on * [Webhooks](/webhooks) is what LevelFour posts to your endpoint, and the signature the chat receivers verify * [API reference](/api-reference) is the REST surface underneath all of it --- # Formatting comments Source: https://docs.levelfour.ai/guides/formatting-comments.md Comments on a savings recommendation take Markdown. Every syntax below works in the comment box, and the last section names the GitHub syntax that does not. > **Note** > > Comments are visible to everyone in your organization who can open the recommendation. Reactions and the Seen by row are visible too. The composer has two tabs. **Write** is the plain-text editor, **Preview** renders the result. > **Success** > > Anything shown in Preview is what other people will see once you post. ## Text styling | You type | You get | | ------------------------------ | ---------------------------- | | `**bold**` | **bold** | | `_italic_` | *italic* | | `~~struck through~~` | ~~struck through~~ | | `` `inline code` `` | `inline code` | | `**bold and _nested italic_**` | **bold and *nested italic*** | ## Headings Start a line with one to six `#` characters, followed by a space. ```markdown # Largest heading ## Second level ### Third level ``` The toolbar's **H** button inserts a `#` at the start of the current line. ## Line breaks and paragraphs A single newline breaks the line. A blank line starts a new paragraph. ```markdown First line. Second line, directly below the first. A new paragraph. ``` ## Lists ```markdown - Unordered item - Another item - Indent two spaces to nest 1. Ordered item 2. Second item ``` Task lists render as checkboxes: ```markdown - [ ] Not done yet - [x] Done ``` Checkboxes in a posted comment are read-only. Edit the comment to tick one. ## Links Write a link as `[label](url)`. A bare URL becomes a link on its own, so `https://levelfour.ai` needs no markup. Links open in a new tab. ## Quotes Prefix a line with `>`: ```markdown > Finance signed off on this in the Q3 review. ``` ## Code Multi-line code goes in a fenced block. Name the language after the opening fence: ````markdown ```bash aws ec2 describe-volumes --filters Name=status,Values=available ``` ```` Fenced blocks keep their whitespace and scroll sideways instead of wrapping, so command output and log excerpts stay readable. ## Tables Separate cells with `|` and put a row of dashes under the header. A colon in the dash row sets the column alignment. ```markdown | Account | Before/mo | Saved/mo | |---|---:|---:| | Production | $641.10 | $209.09 | | Staging | $318.00 | $209.67 | ``` Leave a blank line before the table. A wide table scrolls sideways within the comment rather than stretching it. ## Horizontal rule Three dashes on their own line draw a divider: ```markdown --- ``` ## Mentioning a teammate Type `@` followed by part of a name or email address. Pick someone from the list that appears and LevelFour inserts their name into your comment. LevelFour notifies the person by email and in the app, with a link that opens the recommendation drawer at your comment. You can only mention members of your own organization. > **Note** > > Editing a mention removes it. If you delete part of the name, the whole mention goes with it, so a comment never carries a half-broken reference. ## Attaching images and files Ways to attach: * **Paste.** Copy a screenshot and paste it into the comment box. * **Drag and drop.** Drop the file onto the box. * **Click.** Use the paperclip in the toolbar. Images embed inline. Everything else becomes a download link. > **Note** > > Attachments stay private to your organization. LevelFour stores them in its own bucket and serves them only to signed-in members of your workspace. | Limit | Value | | ------------- | -------------------- | | Maximum size | 10 MB per file | | Image formats | PNG, JPEG, GIF, WebP | | Other formats | PDF | Attaching a file inserts the Markdown for you. Move the cursor first if you want the attachment somewhere other than where you left off. ## Not supported A few things GitHub does that LevelFour comments do not: * **Raw HTML.** Tags show as text instead of rendering. * **Emoji shortcodes.** `:tada:` stays literal. Paste the emoji character instead. * **Issue and pull request autolinks.** `#123` is plain text. Use a full URL. --- # Filtering and sorting Source: https://docs.levelfour.ai/guides/filtering-and-sorting.md LevelFour narrows a query at four tiers, from server-side flags to shell pipes. Pick the one that fits the task: | Tier | What it does | When to use | | ----------------- | ----------------------------------------- | --------------------------- | | Built-in flags | Server-side filter, sort, group, paginate | Standard queries | | Interactive TUI | Visual drill-down with live filters | Exploring data | | `--jq` expression | Post-process JSON with jq syntax | Ad-hoc transforms | | Shell pipes | Compose with grep/awk/xargs/jq | Full grep-style flexibility | Every recipe below has been validated against the live API. [How recipes are validated](/cli/recipes#how-recipes-are-validated) covers the harness. ## Built-in filter flags Every `list`-style subcommand takes repeatable filter flags, a date window, a sort and a page. The full flag tables live on [`l4 costs`](/cli/commands/costs#flags) and [`l4 recommendations`](/cli/commands/recommendations#flags). This is what they look like in use: ```bash # Filter by one or more services, regions, accounts, environments, or tags l4 costs breakdown \ --service EC2 --service RDS \ --region us-east-1 \ --account 123456789012 \ --tag-key Environment --tag-value production # Date window (preset or explicit) l4 costs breakdown --preset 30D # 30D, 6M, 12M l4 costs breakdown --start 2026-01-01 --end 2026-01-31 # Granularity l4 costs breakdown --granularity daily # or monthly # Multi-dimension grouping l4 costs breakdown --group-by service --group-by region # Sort l4 costs breakdown --sort-by cost --sort-order desc # Pagination (max 100 per page) l4 costs breakdown --page 2 --page-size 50 ``` Before filtering, list what values are actually present in your data for a given dimension. This is the same set the dashboard offers in its autocomplete dropdowns: ```bash l4 costs filters # summary of all dimensions l4 costs filters service # all services l4 costs filters region # all regions l4 costs filters account # all accounts l4 costs filters tag-key # all tag keys ``` The bare `l4 costs filters` prints a summary table with counts and sample values. ## Interactive TUI Add `--tui` to either `costs breakdown` or `recommendations list` for visual exploration: ```bash l4 costs breakdown --tui l4 recommendations list --tui ``` Inside the TUI: | Key | Action | | --------- | -------------------------------------------------------- | | `/` | Live search (substring match across all visible columns) | | `f` | Open filter drawer | | `tab` | (in filter mode) cycle dimension | | `enter` | (in filter mode) apply filter and re-fetch | | `F` | Clear all active filters | | `s` / `S` | Cycle sort column / toggle asc↔desc | | `n` / `p` | Next / previous page | | `enter` | Toggle detail pane for selected row | | `?` | Full keyboard help | | `q` | Quit | Any flags passed on the command line seed the TUI's initial state, so `l4 costs breakdown --service RDS --tui` opens with `service=RDS` already applied. > **Success** > > Active filters render in the footer. That row is how you confirm a filter took. ## Filtering with `--jq` `--jq` runs a [jq](https://jqlang.github.io/jq/) expression over the response before display, on every subcommand that returns data. The CLI uses [gojq](https://github.com/itchyny/gojq) internally, so the syntax is compatible with standard jq. [Output formats](/cli/output-formats#--jq) covers the flag itself. > **Note** > > Response envelopes differ between commands. `costs breakdown` wraps data at `.data.items[]` and `recommendations list` wraps at `.data.data.items[]`, because `recommendations list` bundles the provider overview alongside the list. Copy the envelope path from the recipes below. The `-r` (raw output) flag is not available via `--jq`. For raw strings in pipelines, use `--json | jq -r '...'` with your system `jq` instead. ## Shell pipes When stdout is not a terminal the CLI emits no spinner and no ANSI escapes, so pipelines compose cleanly: ```bash l4 recommendations list --json | jq '.data.data.items[].service' | sort | uniq -c ``` ## Recipes > **Note** > > Every recipe below is read-only. None of them accept, reject, or execute recommendations. The write commands are [`l4 recommendations accept`](/cli/commands/recommendations/accept), [`reject`](/cli/commands/recommendations/reject) and [`execute`](/cli/commands/recommendations/execute). > **Warning** > > Every recipe that aggregates with `jq` sees a single page. A count or a TOTAL computed from one describes that page, not your whole backlog. To cover everything, read `total_pages` and loop, the way the [multi-page aggregator](/cli/recipes#aggregate-100-savings-recommendations) does. ### Cost queries #### Top services by cost this month Find the services consuming the largest share of your current cloud spend. The first step in any cost review. ```bash l4 costs breakdown \ --preset 30D \ --sort-by cost --sort-order desc \ --page-size 10 ``` The top 10 services by cost over the last 30 days. Drop `--page-size` for the default 20; raise it to 100 to scan everything. #### Monthly spend trend (all-time) See month-over-month spend across your whole organization, for budget reviews and for spotting seasonality. ```bash l4 costs monthly --json \ | jq -r '.data.data_points[] | [.month, .amount] | @tsv' ``` Tab-separated `YYYY-MM` and dollar amount, one line per month. Pipe to `column -t` for aligned columns. #### Daily spend for a specific window Zoom into a date range to hunt for day-level anomalies. Reach for this when monthly totals look wrong and you want to find the day things spiked. ```bash l4 costs daily --start 2026-03-01 --end 2026-03-31 --json \ | jq -r '.data.data_points[] | [.date, .amount] | @tsv' ``` One row per day in the range. Pipe to `sort -k2 -n` to list days from cheapest to most expensive. #### Cost breakdown by region Which regions are driving your AWS bill? Groups the service list by region so you can compare. ```bash l4 costs breakdown \ --group-by region \ --sort-by cost --sort-order desc \ --page-size 20 ``` Returns region-scoped cost rows. Add `--service EC2` to scope to a specific service within each region. #### EC2 cost by region and account Multi-dimensional grouping: see how EC2 cost breaks down by both region and account, over the last six months. ```bash l4 costs breakdown \ --preset 6M \ --service EC2 \ --group-by region --group-by account_id \ --sort-by cost --sort-order desc \ --page-size 50 ``` Rows grouped by both region and account, sorted by cost. Swap `--preset 6M` for `--preset 30D` to see a shorter window. ### Recommendation queries #### RDS delete candidates (≥99% savings) Find RDS recommendations where LevelFour estimates 99% or more savings. These are usually idle databases or forgotten snapshots where the right action is delete, which makes them the lowest-effort wins in a cost-optimization sprint. ```bash l4 recommendations list --service RDS --page-size 100 --json \ | jq '[.data.data.items[] | select(.savings_percentage >= 99)]' ``` Returns a JSON array of matching recommendations. To get just IDs (useful for scripting): ```bash l4 recommendations list --service RDS --page-size 100 --json \ | jq -r '.data.data.items[] | select(.savings_percentage >= 99) | .recommendation_id' ``` #### Top 5 recommendations by monthly savings Ranked list of the highest-impact recommendations across all services and providers. ```bash l4 recommendations list \ --sort-by monthly_savings --sort-order desc \ --page-size 5 ``` A table of the top 5. For a JSON-friendly version suitable for scripts, add `--json` and pipe to jq. #### Recommendations with ≥50% savings All recommendations that would cut the underlying resource cost in half or more. Anything above 50% is usually worth the engineering time. ```bash l4 recommendations list --page-size 100 --json \ | jq '[.data.data.items[] | select(.savings_percentage >= 50)]' ``` Returns a JSON array. Append `| length` to the jq expression to get just the count. #### Count recommendations by service How are your savings opportunities distributed? This groups the current page of recommendations by service and sorts by count. ```bash l4 recommendations list --page-size 100 --jq \ '[.data.data.items[] | .service] | group_by(.) | map({service: .[0], count: length}) | sort_by(-.count)' ``` An array of `{service, count}` objects sorted highest-first. #### Count recommendations by status See how many recommendations are pending vs optimized vs rejected, for tracking team throughput. ```bash l4 recommendations list --page-size 100 --jq \ '[.data.data.items[] | .status] | group_by(.) | map({status: .[0], count: length})' ``` An array of `{status, count}` objects. #### Bulk-view RDS delete candidates Pipe the RDS delete-candidate IDs straight into `recommendations view` to walk through each one's full detail in sequence. Built for a review session where you want the full justification before approving. ```bash l4 recommendations list --service RDS --page-size 100 --json \ | jq -r '.data.data.items[] | select(.savings_percentage >= 99) | .recommendation_id' \ | xargs -I{} l4 recommendations view {} ``` Prints the full detail for every matching recommendation, in order. Remove the final `| xargs ...` to just get the ID list. #### Export top recommendations to CSV Dump the highest-savings recommendations into CSV for sharing in a spreadsheet or ticket. ```bash l4 recommendations list \ --sort-by monthly_savings --sort-order desc \ --page-size 50 --json \ | jq -r '.data.data.items[] | [.recommendation_id, .service, .monthly_savings, .savings_percentage] | @csv' \ > top-recommendations.csv ``` Writes a CSV with four columns and no header. Prepend a header row: ```bash echo '"id","service","monthly_savings","savings_percentage"' > top-recommendations.csv l4 recommendations list ... | jq -r '... | @csv' >> top-recommendations.csv ``` #### RDS in one account, sorted by savings Scope a review to a single account or environment using primary filter flags, then sort by impact. ```bash l4 recommendations list \ --service RDS \ --account 123456789012 \ --sort-by monthly_savings --sort-order desc \ --page-size 20 ``` A table scoped to the given account. Replace `--account` with `--environment production` or `--tag Environment=production` for tag-based scoping. #### RDS delete-candidates summary table The same RDS recommendations at 99% savings or more, formatted as a scannable table with the recommendation title inline (from `actions.overview`). The format for working through delete candidates in one sitting. ```bash l4 recommendations list --page-size 100 --json \ | jq -r '.data.data.items[] | select(.service == "RDS" and .savings_percentage >= 99) | [.recommendation_id, "$" + (.monthly_savings | tostring) + "/mo", .actions.overview] | @tsv' \ | column -t -s $'\t' ``` A three-column table: ID, monthly savings, one-line title. Sorted highest-savings-first (API default). Change `--service RDS` to any other service or drop the `.service` filter for all providers. #### Recommendations grouped by account with totals Aggregate RDS delete-candidates by account and sum their savings. Useful for planning cleanup work. You see which accounts hold the biggest concentration of delete candidates and what each is worth. ```bash l4 recommendations list --page-size 100 --json \ | jq -r ' ([.data.data.items[] | select(.service == "RDS" and .savings_percentage >= 99) | . + {acct_norm: (.account | sub(" \\(.*\\)$"; ""))} ]) as $rds | ($rds | group_by(.acct_norm) | map({ account: .[0].acct_norm, env: ([.[].environment] | unique | join(", ")), count: length, monthly: (map(.monthly_savings) | add * 100 | round / 100) }) | sort_by(-.monthly)) as $grouped | "ACCOUNT\tENVIRONMENT\tCOUNT\tMONTHLY SAVINGS", "-------\t-----------\t-----\t---------------", ($grouped[] | [.account, .env, .count, "$" + (.monthly | tostring)] | @tsv), "-------\t-----------\t-----\t---------------", "TOTAL\t\t" + ($rds | length | tostring) + "\t$" + ($rds | map(.monthly_savings) | add * 100 | round / 100 | tostring) ' \ | column -t -s $'\t' ``` A four-column table (account, environment, count, total monthly savings) with divider rows and a TOTAL footer. The `sub(" \\(.*\\)$"; "")` normalizes account IDs so `123456789012 (Live)` and `123456789012` merge into one row. ## SDK equivalents The same filters and sort fields are typed parameters on the SDK list methods. Use these when you are scripting in a programming language instead of the shell. Client construction lives on the [Python](/sdks/python), [TypeScript](/sdks/typescript) and [Go](/sdks/go) SDK pages. **Python** ```python from levelfour import LevelFour client = LevelFour() # RDS delete candidates recs = client.recommendations.list_by_provider( "aws", service=["RDS"], page_size=100, ) candidates = [r for r in recs if r.savings_percentage >= 99] ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); const candidates = []; for await (const rec of await client.recommendations.listByProvider("aws", { service: ["RDS"], page_size: 100, })) { if (rec.savings_percentage >= 99) candidates.push(rec); } ``` **Go** ```go ctx := context.Background() page, err := client.Recommendations.ListByProvider(ctx, "aws", &levelfour.ListByProviderRecommendationsRequest{ Service: []string{"RDS"}, PageSize: levelfour.Int(100), }) if err != nil { log.Fatal(err) } iter := page.Iterator() for iter.Next(ctx) { rec := iter.Current() if rec.SavingsPercentage >= 99 { fmt.Println(rec.RecommendationID) } } ``` --- # Virtual tags Source: https://docs.levelfour.ai/guides/virtual-tags.md A virtual tag is a tag key LevelFour assigns to each line of your AWS and GCP bills, from rules you write. Your provider tags stay as they are in the bill. The virtual key sits beside them, and you group, filter and split costs by it the way you would by any tag. Use one when: * your tags disagree, as when one team shows up as `team=data`, `team=Data` and `team=data-prod` * ownership follows an account, a project or a service rather than a tag * one key has to cover AWS tags and GCP labels * a shared cost, such as the AWS Support fee, has to be divided between teams > **Warning** > > Virtual tags read detailed cost data: the [Cost and Usage Report](/connectors/aws/detailed-cost-data) on AWS and the [detailed billing export](/connectors/gcp/billing-export) on GCP. LevelFour cannot assign a value to spend in an account without it, and a per-team total then leaves that account out. `GET /api/v1/tags/dimensions` names those accounts under `capabilities.accounts_without_detailed_data`. ## How LevelFour picks a value For each cost line, LevelFour works down this list and stops at the first step that produces a value: 1. **The provider's value**, when the virtual key has the same name as a provider key and [Can Override](#extend-or-replace-a-provider-key) is off. A line that already carries the provider key keeps that value. 2. **Collapsed keys**, in order. The first source key present on the line supplies the value. 3. **Configs**, in order. The first config whose time frame covers the line's date and whose filter matches assigns its value, or splits the line across several values. 4. **The provider's value**, when Can Override is on and no step above matched. 5. **Unallocated**, for any line left. A config's filter holds a list of rules, and one matching rule is enough. A rule matches when the line comes from one of the rule's providers and all of its conditions hold. Position is the only precedence. To pin a narrow case, such as one resource owned by another team, put its config above the broad config that would claim it. [`l4 tags preview`](/cli/commands/tags/preview) reports the spend a lower config matches after a higher one has taken it. ## Build a Teams key The examples below build one `Teams` key. A config is one entry in the key's ordered list of values, with its rules underneath. The key is a YAML file you pass to [`l4 tags apply`](/cli/commands/tags/apply), and [the file format](/cli/commands/tags/apply#file-format) lists every field. Each snippet is one entry under `configs:`, and [the whole file](#the-whole-file) puts them together. ### One value across two providers AWS bills Amazon Redshift, GCP bills BigQuery, and the data team owns both. One rule covers both providers, and its condition lists both service names: ```yaml - value: data rules: - providers: [aws, gcp] where: - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]} ``` A condition with several values matches any one of them. ### An AWS account and a GCP project The mobile team owns the AWS account `111122223333` and the GCP project `mobile-prod`. Give the `mobile` value one rule per provider. The `account` dimension reads the account ID on AWS and the project ID on GCP: ```yaml - value: mobile rules: - providers: [aws] where: - {dimension: account, values: ["111122223333"]} - providers: [gcp] where: - {dimension: account, values: [mobile-prod]} ``` `operator` defaults to `is`, so these conditions leave it out. Quote account IDs so YAML keeps them as text. ### Fold several spellings into one value On AWS, the data team's resources carry `team=data`, `team=data-prod` and `team=Data`. On GCP they carry the label `team=data`. Add two rules to the `data` config and all of them become `data`: ```yaml - value: data rules: - providers: [aws, gcp] where: - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]} - providers: [aws] where: - {dimension: tag, key: team, values: [data, data-prod, Data]} - providers: [gcp] where: - {dimension: tag, key: team, values: [data]} ``` A `tag` condition names its key in `key`. `is` is case-sensitive, so `Data` needs its own entry next to `data`. ### Match spellings nobody has typed yet `flexible_match` ignores case, spaces, hyphens and underscores on both sides. One condition on `Team A` matches `teamA`, `team_a`, `team-a` and `TEAM A`: ```yaml - value: team-a rules: - providers: [aws, gcp] where: - {dimension: tag, key: team, operator: flexible_match, values: [Team A]} ``` A resource someone tags `Team-A` next quarter lands in `team-a` with no change to the key. ### Change the owner on a date The infrastructure team ran the `eks-prod` cluster until June 30, and the platform team took it over on July 1. Two configs with the same rule and adjoining time frames keep both halves right: ```yaml - value: infrastructure time_frame: {end: "2026-06-30"} rules: - providers: [aws] where: - {dimension: resource, operator: contains, values: [eks-prod]} - value: platform time_frame: {start: "2026-07-01"} rules: - providers: [aws] where: - {dimension: resource, operator: contains, values: [eks-prod]} ``` `time_frame` takes `start`, `end` or both, as `YYYY-MM-DD`, and LevelFour compares them with each cost line's date. A config without one applies on every date. ### Split a shared account by percentage Account `999900001111` runs shared services, and the teams agreed on 60 percent for `infra` and 40 for `mobile`: ```yaml - split: - {value: infra, pct: 60} - {value: mobile, pct: 40} rules: - providers: [aws] where: - {dimension: account, values: ["999900001111"]} ``` Each matched line becomes one line per value, its cost multiplied by the percentage. The percentages add up to 100, and a value appears once per split. ### Split a support fee by usage The AWS Support fee serves every team. A cost-based config divides it in proportion to each team's AWS usage: ```yaml - cost_based: source: {origin: provider, key: team} input: - providers: [aws] where: - {dimension: charge_type, values: [Usage]} rules: - providers: [aws] where: - {dimension: service, operator: starts_with, values: [AWS Support]} ``` For each day and provider, LevelFour measures how the cost that `input` matches divides across the values of `source`. It then splits each line that `rules` matches in the same proportions. When lines tagged `team=data` carry 30 percent of a day's AWS usage, `data` takes 30 percent of that day's support fee. The shares follow `source` as it reads on the bill, so `data` and `Data` receive separate shares. `source` can also name another virtual key, though not the key you are defining. ### Backfill past months `effective_from` is the first billing month LevelFour applies the key to, as `YYYY-MM`. Leave it out and the key starts with the current month. Past months are reprocessed from it. ```yaml name: Teams effective_from: "2026-06" ``` Saving a change to the configs, the collapsed keys, Can Override or `effective_from` reprocesses the key from that month on. The key's status reads `reprocessing` until the job finishes, and [`l4 tags show`](/cli/commands/tags/show#reprocessing-jobs) reports its progress. Months before `effective_from` carry no value for the key, and [Limits](#limits) caps how far back it reaches. On AWS the job rebuilds a month it no longer holds by reading the Cost and Usage Report again, so backfill reaches the full window. On GCP it reaches only as far as the billing export LevelFour has already loaded, and an earlier month stays empty for the key. ### Extend or replace a provider key Name a virtual key after a provider key, `team` for instance, and it builds on that key's values. `can_override` settles lines where both have an answer: | Line | `can_override: false` (default) | `can_override: true` | | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Carries the provider key | Keeps the provider value | Takes the first collapsed key or config that matches, and keeps the provider value when none does | | Lacks the provider key | Takes the first collapsed key or config that matches | Takes the first collapsed key or config that matches | With `false`, the key fills gaps: untagged lines get a value and tagged ones keep theirs. With `true`, your rules correct what the provider tags say. ## Collapse several keys into one Your teams spell one key three ways: `environment` on most resources, `env` on older AWS ones, `Environment` on GCP labels. A collapsed key copies the value of a source key, and the first source present on a line wins: ```yaml title="environments.yaml" name: Environments collapsed_keys: - source: {origin: provider, key: environment} - source: {origin: provider, key: env} providers: [aws] - source: {origin: provider, key: Environment} providers: [gcp] configs: [] ``` `providers` limits a source to one provider's lines. `prefix` puts text in front of the copied value, so `prefix: "gcp-"` turns `prod` into `gcp-prod` and keeps two sources apart. A source can also be a virtual key, with `origin: virtual`. LevelFour applies collapsed keys before any config. On the `Teams` key, a collapsed source on `team` would copy `Data` through before the rule that folds it into `data` ran. ## Nest one virtual tag inside another A `virtual_tag` condition reads the value another virtual key assigned. Say an `Applications` key maps resources to `checkout`, `search` and `billing`. A `Business units` key rolls them up: ```yaml title="business-units.yaml" name: Business units configs: - value: commerce rules: - providers: [aws, gcp] where: - {dimension: virtual_tag, key: Applications, values: [checkout, search]} - value: finance rules: - providers: [aws, gcp] where: - {dimension: virtual_tag, key: Applications, values: [billing]} ``` LevelFour evaluates `Applications` first, so the condition reads its finished values. The API refuses a definition that breaks any of these: * a key that another key reads cannot hold split or cost-based configs * two keys cannot read each other in a loop * a chain of nested keys stops at the depth in [Limits](#limits) LevelFour also refuses to delete `Applications` while `Business units` reads it. ## The whole file The `Teams` key with every config above, in priority order: ```yaml title="teams.yaml" name: Teams description: Owning team across AWS and GCP. effective_from: "2026-06" can_override: false configs: - value: infrastructure time_frame: {end: "2026-06-30"} rules: - providers: [aws] where: - {dimension: resource, operator: contains, values: [eks-prod]} - value: platform time_frame: {start: "2026-07-01"} rules: - providers: [aws] where: - {dimension: resource, operator: contains, values: [eks-prod]} - cost_based: source: {origin: provider, key: team} input: - providers: [aws] where: - {dimension: charge_type, values: [Usage]} rules: - providers: [aws] where: - {dimension: service, operator: starts_with, values: [AWS Support]} - value: data rules: - providers: [aws, gcp] where: - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]} - providers: [aws] where: - {dimension: tag, key: team, values: [data, data-prod, Data]} - providers: [gcp] where: - {dimension: tag, key: team, values: [data]} - value: mobile rules: - providers: [aws] where: - {dimension: account, values: ["111122223333"]} - providers: [gcp] where: - {dimension: account, values: [mobile-prod]} - value: team-a rules: - providers: [aws, gcp] where: - {dimension: tag, key: team, operator: flexible_match, values: [Team A]} - split: - {value: infra, pct: 60} - {value: mobile, pct: 40} rules: - providers: [aws] where: - {dimension: account, values: ["999900001111"]} ``` The narrow configs sit on top. The support fee comes before `mobile`, so a support line billed to the mobile account still gets split by usage. A line in the shared account tagged `team=data` goes to `data`, because that config sits above the split. Move the split up and the shared account wins instead. Try the file, then save it: ```bash l4 tags preview -f teams.yaml l4 tags apply -f teams.yaml ``` ## Dimensions and operators | Operator | Matches when the line's value | | -------------------- | ------------------------------------------------------------------------------- | | `is` (default) | equals one of `values` | | `is_not` | equals none of `values` | | `contains` | contains one of `values` | | `not_contains` | contains none of `values` | | `starts_with` | starts with one of `values` | | `ends_with` | ends with one of `values` | | `flexible_match` | equals one of `values` once case, spaces, `-` and `_` are ignored on both sides | | `not_flexible_match` | equals none of `values` under the same comparison | Every operator except the flexible pair is case-sensitive. > **Warning** > > A negative operator on a `tag` condition also matches lines that lack the key. `team is_not data` catches untagged cost along with the other teams' cost. To catch only lines tagged with another team, add `{dimension: tagged, key: team}` to the same rule. Conditions read these dimensions straight from the bill: `account`, `billing_account`, `region`, `service`, `category`, `subcategory`, `usage_unit`, `resource`, `charge_type` and `marketplace`. `category` and `marketplace` exist on AWS only. The rest read keys: * `tag` matches the value of a provider tag or GCP label, and needs `key` * `tagged` checks that a key is present and takes no values. Leave out `key` to match a line carrying any key * `virtual_tag` matches the value of another virtual key, and needs `key` The [Tags API reference](/api-reference/tags) maps each dimension to its AWS and GCP billing column. `GET /api/v1/tags/dimensions` returns the catalog per provider, and `GET /api/v1/tags/dimensions/{dimension}/values` returns the values a condition can match in your data. ## Limits | Limit | Value | | ----------------------------- | ------------- | | Virtual keys per organization | 50 | | Configs per key | 100 | | Values per key | 100 | | Rules per config | 20 | | Conditions per rule | 10 | | Values per condition | 200 | | Collapsed keys per key | 20 | | Value prefix | 64 characters | | Nesting depth | 5 | | Preview window | 31 days | | Backfill | 12 months | A definition over a limit fails with `limit_exceeded`. ## Unallocated cost A line no step assigns is unallocated. A breakdown grouped by a virtual key shows that cost as its own group, so the groups add up to the bill. Without it, the per-team totals would sum to less than your spend, with nothing to say why. The API returns the group as `__unallocated__`. [`l4 tags show`](/cli/commands/tags/show) prints a key's `unallocated_spend`, and [`l4 tags coverage`](/cli/commands/tags/coverage) compares the share of spend provider tags cover with the share covered once virtual tags are added. To shrink it, draft a config, run [`l4 tags preview`](/cli/commands/tags/preview), and check how much spend leaves unallocated before you save. ## Where virtual keys appear A saved virtual key works where a provider tag key does: * The provider cost breakdown groups by it (`group_by=virtual_tag`) and filters by its values. Split lines arrive with their cost divided, so totals still match the bill. * Costs by tag and cost allocation take a virtual key in `tag_key`. * Recommendations carry `virtual_tags`, the value of each virtual key on the recommendation, and filter by `virtual_tag_key` and `virtual_tag_value`. * [`l4 tags costs`](/cli/commands/tags/costs) prints spend per value, and [`l4 costs breakdown`](/cli/commands/costs) groups and filters by a key with `--virtual-tag-key`. * The MCP server reads a key, tries rules before they are saved, and defines and edits one. [Tools](/mcp/tools) is generated from the surface the server advertises and names each one. ## Permissions Any member, and any API key, can read virtual tags. Creating, changing or deleting one takes the `org:tags:write` permission, which a read-write API key carries. [Key scopes](/authentication#key-scopes) covers the two kinds of key. ## Next * [`l4 tags`](/cli/commands/tags) keeps virtual keys in YAML files you apply from the terminal * [Costs](/resources/costs) reads the breakdowns a virtual key groups * [Tags API reference](/api-reference/tags) has every endpoint and every validation code --- # GitHub Actions Source: https://docs.levelfour.ai/guides/github-actions.md Three workflows, each a whole file you copy into `.github/workflows/`. One comments a cost summary on every pull request, one runs a scheduled check with the Python or TypeScript SDK, and one uploads a daily report as a build artifact. > **Note** > > Nothing on this page uses the `l4` CLI. Terraform and CloudFormation cost estimates and diff checks inside a workflow live on [CI/CD Integration](/guides/ci-cd). ## Prerequisites * A LevelFour API key. [Authentication](/authentication) covers where keys are created, the two key formats, and rotation. * The key stored as a GitHub Actions secret named `LEVELFOUR_API_KEY`. > **Tip** > > Every request on this page reads, so a `read` key covers all three workflows. See [Key scopes](/authentication#key-scopes). ## Comment a cost summary on a pull request The job needs `pull-requests: write`, which is the only permission it asks for. ````yaml title=".github/workflows/cost-report.yml" name: Cost Report on: pull_request: types: [opened, synchronize] jobs: cost-report: runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Get savings summary id: summary env: LEVELFOUR_API_KEY: ${{ secrets.LEVELFOUR_API_KEY }} run: | RESPONSE=$(curl -s \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ https://api.levelfour.ai/api/v1/recommendations/savings-by-provider) echo "body<> $GITHUB_OUTPUT echo "## Cloud Cost Summary" >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT echo '```json' >> $GITHUB_OUTPUT echo "$RESPONSE" | python3 -m json.tool >> $GITHUB_OUTPUT echo '```' >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Comment on PR uses: actions/github-script@v7 with: script: | const body = process.env.BODY; github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: body, }); env: BODY: ${{ steps.summary.outputs.body }} ```` > **Success** > > A working run leaves one comment on the pull request, headed **Cloud Cost Summary**, with the response JSON fenced underneath it. ## Scheduled cost check The same job runs in Python or in TypeScript, on a weekday schedule, and prints what it finds to the job log. Each SDK reads `LEVELFOUR_API_KEY` from the environment, so the client is constructed with no arguments. [Python SDK](/sdks/python) and [TypeScript SDK](/sdks/typescript) cover the rest of each client. **Python** ```yaml title=".github/workflows/cost-check.yml" name: Cost Check (Python) on: schedule: - cron: '0 9 * * 1-5' jobs: report: runs-on: ubuntu-latest steps: - uses: actions/setup-python@v5 with: python-version: '3.11' - run: pip install levelfour - name: Run cost check env: LEVELFOUR_API_KEY: ${{ secrets.LEVELFOUR_API_KEY }} run: | python3 -c " from levelfour import LevelFour client = LevelFour() summary = client.recommendations.get_savings_by_provider() print(summary) overview = client.recommendations.get_overview() print(overview) costs = client.costs.get_summary() print(costs) " ``` **TypeScript** ```yaml title=".github/workflows/cost-check.yml" name: Cost Check (TypeScript) on: schedule: - cron: '0 9 * * 1-5' jobs: report: runs-on: ubuntu-latest steps: - uses: actions/setup-node@v4 with: node-version: '22' - run: npm install levelfour - name: Run cost check env: LEVELFOUR_API_KEY: ${{ secrets.LEVELFOUR_API_KEY }} run: | npx tsx -e " import { LevelFourClient } from 'levelfour'; const client = new LevelFourClient(); const summary = await client.recommendations.getSavingsByProvider(); console.log(JSON.stringify(summary, null, 2)); const costs = await client.costs.getSummary(); console.log(JSON.stringify(costs, null, 2)); " ``` ## Daily report as a build artifact The job writes `costs.json` and `recommendations.json`, then uploads both under the artifact name `cost-report-`. ```yaml title=".github/workflows/daily-cost-report.yml" name: Daily Cost Report on: schedule: - cron: '0 9 * * 1-5' jobs: report: runs-on: ubuntu-latest steps: - name: Fetch cost summary env: LEVELFOUR_API_KEY: ${{ secrets.LEVELFOUR_API_KEY }} run: | curl -s \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ https://api.levelfour.ai/api/v1/costs/summary \ | python3 -m json.tool > costs.json - name: Fetch recommendations env: LEVELFOUR_API_KEY: ${{ secrets.LEVELFOUR_API_KEY }} run: | curl -s \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ "https://api.levelfour.ai/api/v1/recommendations?page=1&page_size=100" \ | python3 -m json.tool > recommendations.json - name: Upload report uses: actions/upload-artifact@v4 with: name: cost-report-${{ github.run_id }} path: | costs.json recommendations.json ``` > **Warning** > > The recommendations request fetches page one and stops there. An organization with more recommendations than that page holds gets a truncated file and no warning, because the workflow never reads the pagination metadata the response carries. [Pagination](/pagination) has the fields to check and how to walk the rest. ## Next * [CI/CD Integration](/guides/ci-cd) is the `l4` CLI equivalent: Terraform and CloudFormation cost estimates and diffs that gate a pull request * [Authentication](/authentication) covers key creation, formats and scopes * [Python SDK](/sdks/python) and [TypeScript SDK](/sdks/typescript) are the clients behind the scheduled check * [Scheduled Execution](/guides/scheduled-execution) monitors savings execution on a recurring schedule --- # PR bot configuration Source: https://docs.levelfour.ai/guides/pr-bot-configuration.md Once you [install LevelFour on a GitHub organization](/integrations/github), the PR bot is **on by default** for every repository you granted access to. On every pull request, LevelFour reads the Terraform and CloudFormation changes in the diff, computes cost impact, and posts a single check-run comment with the cost breakdown. When LevelFour has concrete patch suggestions, they appear as inline "Suggested change" boxes on the diff itself. The main comment does not list them. You tune the bot per repo with a `.levelfour/config.yml` file at the root of the default branch. The file is optional. With no file, the bot uses the defaults documented below. > **Note** > > A dashboard-driven org and per-repo toggle is on the roadmap, so you will be able to silence the bot across thousands of repos at once, or roll it out in stages, without committing a YAML file to every repo. The toggle will take precedence over `.levelfour/config.yml`. Until then, every per-repo override lives in this file. ## Where the file lives Path: `.levelfour/config.yml` at the repository root, on the **default branch**. The bot reads this file at the start of every pull request analysis. Changes take effect on the next PR or push, never on an analysis that has already run. To check a file before it matters, copy one of the examples below into your repo and open a draft PR. [Validation behavior](#validation-behavior) covers what comes back. ## Quick examples ### Disable the bot on a single repository ```yaml title=".levelfour/config.yml" enabled: false ``` > **Success** > > The next pull request on this repo gets no comment and no check run. This does not stop LevelFour reading the repo. See [what this file does not control](#what-this-file-does-not-control). ### Drop inline patch suggestions, keep the cost comment ```yaml title=".levelfour/config.yml" code_suggestions_enabled: false ``` The bot still posts the cost summary on each PR, but does not propose inline "Suggested change" boxes on the diff. ### Exclude test fixtures and a noisy resource type ```yaml title=".levelfour/config.yml" excluded_paths: - tests/fixtures/** - examples/** excluded_resource_types: - aws_iam_role_policy_attachment - AWS::Logs::* ``` Write each type the way its own language does: `aws_*` for Terraform, `AWS::Service::Resource` for CloudFormation. ### Price a CloudFormation template with its production parameters ```yaml title=".levelfour/config.yml" cloudformation: samconfig_env: prod parameter_files: stacks/app.yaml: stacks/params/prod.json ``` The bot prices `stacks/app.yaml` with the values in `stacks/params/prod.json`, and reads the `prod` environment from any `samconfig` file beside a template. [CloudFormation](#cloudformation) has the order the bot reads them in. ### Pin all cost estimates to a specific region ```yaml title=".levelfour/config.yml" region_override: us-east-1 ``` Reach for this when your Terraform omits `provider "aws" { region = ... }` and the bot would otherwise default to the global price. For CloudFormation it also wins over the region in `samconfig`. ## Schema | Field | Type | Default | What it does | | --------------------------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `enabled` | bool | `true` | Master switch. When `false`, the bot skips this repo. No comments, no check runs. | | `code_suggestions_enabled` | bool | `true` | When `false`, the bot still posts the cost comment but does not propose inline patch suggestions. | | `max_code_suggestions` | int (0 to 25) | `25` | Cap on inline suggestion comments per PR. A suggestion that needs a second setting changed with it, such as gp3 with its IOPS, posts as one comment per line and counts each one. The bot never posts part of such a pair. Lower it if the bot is too chatty on large changes. | | `excluded_resource_types` | string\[] | `[]` | Resource type globs to skip, in each language's own type names. Example: `aws_iam_*` matches every Terraform IAM resource, `AWS::IAM::*` every CloudFormation one. | | `excluded_paths` | string\[] | `[]` | File path globs to skip, for Terraform files and CloudFormation templates alike. Example: `tests/fixtures/**`. | | `region_override` | string or null | `null` | Pin all cost estimates to one AWS region. | | `cloudformation.enabled` | bool | `true` | When `false`, the bot skips CloudFormation templates and prices Terraform only. | | `cloudformation.paths` | string\[] | `[]` | Path globs that pick which templates the bot prices. Empty picks every template in the repository. A template outside them is still read when a picked template nests it. | | `cloudformation.samconfig_env` | string | `default` | The `samconfig` environment whose `parameter_overrides` and region apply. | | `cloudformation.parameter_files` | map | `{}` | A parameters file for each template, both as paths from the repository root. | | `graviton_for_managed_services_enabled` | bool | `false` | Opt in to Graviton-vs-Intel suggestions for AWS managed services (RDS, ElastiCache, OpenSearch). Off by default while the rule is being tuned. | | `comment_sections.summary` | bool | `true` | Show the top-of-comment summary block. | | `comment_sections.cost_breakdown` | bool | `true` | Show the per-resource cost diff table. | | `comment_sections.monthly_spend` | bool | `true` | Show the monthly spend total. | | `comment_sections.optimization_tips` | bool | `true` | Accepted so older files raise no unknown-field warning. It changes nothing: suggestions appear only inline on the diff, and `code_suggestions_enabled` turns those off. | ## Limits `excluded_paths`, `excluded_resource_types` and `cloudformation.paths` each take at most 100 globs of up to 256 characters. `cloudformation.parameter_files` takes at most 400 entries. The bot drops the rest and warns on the PR comment. ## Glob syntax for `excluded_paths`, `excluded_resource_types` and `cloudformation.paths` A `*` matches any run of characters, slashes included: * `tests/fixtures/**` matches every path under `tests/fixtures/`. * `aws_iam_*` matches every Terraform resource type starting with `aws_iam_`. * `AWS::Lambda::*` matches every CloudFormation Lambda type. * `examples/*.tf` matches every Terraform file under `examples/`, in subdirectories too. Without `*`, the bot matches the string exactly. ## CloudFormation A parameter takes the first value the bot finds: 1. The file `cloudformation.parameter_files` maps to the template 2. `parameter_overrides` in the `samconfig.toml`, `samconfig.yaml` or `samconfig.yml` in the template's directory, under the `cloudformation.samconfig_env` environment. A `samconfig` applies to the template its `template_file` names. With no `template_file`, it applies to `template.yaml`, `template.yml` or `template.json` in its directory 3. The `Default` the template declares A parameters file is JSON in the AWS CLI format or the CodePipeline template configuration format: **AWS CLI** ```json title="stacks/params/prod.json" [ { "ParameterKey": "InstanceType", "ParameterValue": "m7g.large" }, { "ParameterKey": "Environment", "ParameterValue": "prod" } ] ``` **CodePipeline** ```json title="stacks/params/prod.json" { "Parameters": { "InstanceType": "m7g.large", "Environment": "prod" } } ``` The region comes from `region_override`, then from the `samconfig` environment, then defaults to `us-east-1`. A nested stack takes the parameters its parent passes down and the parent's region. The cost comment lists the values the bot had to assume under **Assumptions**. [Pull request bot](/integrations/pull-request-bot#how-it-prices-a-cloudformation-template) covers what the bot reads and what it cannot price. ## Validation behavior The bot validates `.levelfour/config.yml` on every PR. | Situation | What the bot does | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | An unknown field, at the top level or under `comment_sections` | Strips it silently from the parsed config, and reports it as a warning at the bottom of the PR comment | | A type mismatch, for example a number where a bool is expected | Falls back to defaults for that field, and warns on the PR | | No file | Uses all defaults. This is the expected state for most repos | > **Warning** > > A misspelled key is stripped, so `enabld: false` leaves the bot enabled and commenting. Read the unknown-field warning on the PR comment before you assume a repo is silent. ## What this file does not control * **Whether LevelFour can read the repo.** The GitHub App's repository access, set in your GitHub org settings, decides that. Even with `enabled: false`, LevelFour still reads the Infrastructure-as-Code for the savings recommendations engine. To stop LevelFour reading a repo at all, remove it from the GitHub App installation. See [GitHub onboarding](/integrations/github). * **PR creation by LevelFour.** This file affects PRs your team opens. The savings PRs LevelFour opens come from the recommendation engine and ignore `enabled`. * **Check runs the dashboard executes.** Cost recommendation runs from the dashboard ignore this file. ## Next * [GitHub onboarding](/integrations/github) is how the app gets installed, and where you change which repos it can see * [GitHub Actions](/guides/github-actions) is a separate feature: calling the LevelFour API from your own CI workflows --- # CI/CD Integration Source: https://docs.levelfour.ai/guides/ci-cd.md The LevelFour CLI (`l4`) prices the Terraform and CloudFormation in a workspace inside a CI job, so a cost regression fails the pull request instead of arriving on next month's bill. * `l4 estimate` prices everything the workspace defines. * `l4 diff` prices the change against a baseline. Both take `--fail-above`. Over the threshold they exit `2`, and that exit code is the gate. > **Note** > > The two thresholds measure different things. On `l4 estimate` the threshold applies to the absolute monthly cost of the whole workspace, so a repository that is already expensive trips it on every pull request. On `l4 diff` it applies only to the change. Store the API token as a CI secret and expose it as `LEVELFOUR_TOKEN`, as the configurations below do. A cost gate only reads, so a `read` key is enough. [CLI authentication](/cli/authentication) has the resolution order and the read versus read-write split. ## Estimating a workspace ```bash l4 estimate ./infra/ ``` Example output: ```text Name Quantity Unit Monthly Cost aws_instance.bastion ├─ Instance usage (Linux, t4g.micro) 730 hours $6.13 └─ root_block_device └─ Storage (General Purpose, gp3) 30 GB $2.40 aws_ecs_service.api └─ Fargate vCPU 0.25 vCPU $7.39 aws_db_instance.this └─ Instance usage (db.t4g.micro) 730 hours $12.41 Total $52.29 ``` ### Gating on the total Exit `2` when the estimated monthly cost is above a threshold: ```bash l4 estimate ./infra/ --fail-above 500 -q echo $? ``` ```text 0 ``` With a lower threshold: ```bash l4 estimate ./infra/ --fail-above 1 -q echo $? ``` ```text Cost delta $52.29 exceeds threshold $1.00 2 ``` > **Success** > > Under the threshold the command exits `0`, over it `2`. The `-q` (quiet) flag suppresses the table output and communicates only via exit code, so a runner has one thing to read. ### Formatting for a pull request comment `--format github-comment` emits a Markdown table you can pipe straight into a comment: ```markdown ## Cost Estimate | Module | Resource | Type | Cost/mo | Delta | |--------|----------|------|---------|-------| | | + bastion | aws_instance | $8.53 | +$8.53 | | | + api | aws_ecs_service | $7.39 | +$7.39 | | `module.db` | + this | aws_db_instance | $13.98 | +$13.98 | ``` `--out-file baseline.json` writes the resource snapshot instead, for `l4 diff` to compare against later. [`l4 estimate`](/cli/commands/estimate) covers both, along with the `table` and `json` formats. ## Diffing a change `l4 diff` reports the monthly delta between the workspace and a baseline. Pick the baseline: | Baseline | Command | | ------------------------------------------------------- | --------------------------------- | | The git merge-base with `main` or `master`, the default | `l4 diff ./infra/` | | A specific git ref | `l4 diff --base develop ./infra/` | | A snapshot saved by `l4 estimate --out-file` | `l4 diff baseline.json ./infra/` | > **Warning** > > The default baseline needs git history. Both runners below check out a shallow clone by default, which leaves no merge-base to compare against, so the configurations set `fetch-depth: 0` on GitHub Actions and `GIT_DEPTH: 0` on GitLab CI. Where a runner has no git history at all, diff against a snapshot. The same flag gates the delta: ```bash l4 diff ./infra/ --fail-above 100 --format github-comment ``` ## Wiring it into a pipeline > **Error** > > A job with no token exits `4`, not `2`. A gate that branches on any non-zero exit reports a cost breach when the real problem is an unset secret, so branch on `2`. [Exit codes](/cli#exit-codes) has the full set. Each configuration downloads the release binary in the job. [Installation](/cli/installation) covers Homebrew, `go install`, and pinning a version inside a CI image. **GitHub Actions** Comment the estimate on every pull request that touches `infra/`: ```yaml title=".github/workflows/cost-estimate.yml" name: Cost Estimate on: pull_request: paths: - 'infra/**' jobs: cost-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install LevelFour CLI run: | curl -sSL https://github.com/LevelFourAI/levelfour-cli/releases/latest/download/l4_linux_amd64.tar.gz | tar xz sudo mv l4 /usr/local/bin/ - name: Estimate costs id: estimate env: LEVELFOUR_TOKEN: ${{ secrets.LEVELFOUR_TOKEN }} run: | l4 estimate ./infra/ --format github-comment > cost-estimate.md - name: Comment on PR uses: actions/github-script@v7 with: script: | const fs = require('fs'); const body = fs.readFileSync('cost-estimate.md', 'utf8'); github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: body, }); ``` Gate the delta, and comment either way: ```yaml title=".github/workflows/cost-diff.yml" name: Cost Diff on: pull_request: paths: - 'infra/**' jobs: cost-diff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install LevelFour CLI run: | curl -sSL https://github.com/LevelFourAI/levelfour-cli/releases/latest/download/l4_linux_amd64.tar.gz | tar xz sudo mv l4 /usr/local/bin/ - name: Cost diff id: diff env: LEVELFOUR_TOKEN: ${{ secrets.LEVELFOUR_TOKEN }} run: | l4 diff ./infra/ --format github-comment --fail-above 100 > cost-diff.md || echo "exit_code=$?" >> $GITHUB_OUTPUT - name: Comment on PR if: always() uses: actions/github-script@v7 with: script: | const fs = require('fs'); const body = fs.readFileSync('cost-diff.md', 'utf8'); github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: body, }); - name: Fail if over threshold if: steps.diff.outputs.exit_code == '2' run: exit 1 ``` **GitLab CI** Two jobs in one file. The first gates the absolute cost, the second gates the delta. ```yaml title=".gitlab-ci.yml" cost-estimate: stage: validate image: ubuntu:latest before_script: - curl -sSL https://github.com/LevelFourAI/levelfour-cli/releases/latest/download/l4_linux_amd64.tar.gz | tar xz - mv l4 /usr/local/bin/ script: - l4 estimate ./infra/ --fail-above 500 --format table variables: LEVELFOUR_TOKEN: $LEVELFOUR_TOKEN rules: - changes: - infra/** cost-diff: stage: validate image: ubuntu:latest before_script: - curl -sSL https://github.com/LevelFourAI/levelfour-cli/releases/latest/download/l4_linux_amd64.tar.gz | tar xz - mv l4 /usr/local/bin/ script: - l4 diff ./infra/ --fail-above 100 --format table variables: LEVELFOUR_TOKEN: $LEVELFOUR_TOKEN GIT_DEPTH: 0 rules: - changes: - infra/** ``` ## Flags The defaults that matter in a pipeline: | Flag | Default | Description | | ----------------- | ------- | ------------------------------------------------------------------------------------------------------ | | `--fail-above` | `0` | Exit code 2 if the monthly cost (`l4 estimate`) or the monthly delta (`l4 diff`) exceeds the threshold | | `--max-resources` | `500` | Maximum number of resources to include | [`l4 estimate`](/cli/commands/estimate#flags) and [`l4 diff`](/cli/commands/diff#flags) carry the rest: pricing regions, Terraform variables, CloudFormation parameters, remote module downloads and output format. ## Next * [`l4 estimate`](/cli/commands/estimate) prices a workspace, with every flag and default * [`l4 diff`](/cli/commands/diff) does the same for a change against a baseline * [Exit codes](/cli#exit-codes) are what a job should branch on * [Installation](/cli/installation) covers Homebrew, `go install`, and pinning a version inside a CI image * [CLI authentication](/cli/authentication) has the token resolution order and the read versus read-write split * [Output formats](/cli/output-formats) covers `--json`, `--jq`, `--template` and `--csv` * [GitHub Actions](/guides/github-actions) posts cost reports through the API and SDKs rather than the CLI --- # Integrate the SDK into your CLI Source: https://docs.levelfour.ai/guides/integrate-sdk-into-your-cli.md Embedding the SDK as a library adds LevelFour subcommands to a CLI your team already ships: `acme-cli costs summary`, `acme-cli recs list`. You get typed objects and language-native error handling, and the LevelFour calls sit in the same code as the rest of your commands. For an alternative that adds no dependency and shells out to the official `l4` binary, see [Integrate `l4` into your CLI](/guides/integrate-l4-into-your-cli). [Embed or shell out](#embed-or-shell-out) compares the two. ## Pattern 1. Install the SDK matching your host CLI's language 2. Construct a client once, share via your CLI's context object 3. Map SDK responses into your CLI's existing output formatter 4. Map SDK typed errors to your CLI's exit codes 5. Use the SDK's iterators for paginated endpoints The status code to error class table, for all three languages, is in [Error handling](/error-handling). ## Wire it into your CLI **Python (Click)** ```bash pip install levelfour click ``` ```python title="cli.py" from __future__ import annotations import sys import click from levelfour import LevelFour from levelfour.exceptions import ( LevelFourError, AuthenticationError, NotFoundError, RateLimitError, ) def make_client(ctx): if "l4" not in ctx.obj: ctx.obj["l4"] = LevelFour() return ctx.obj["l4"] @click.group() @click.pass_context def cli(ctx): ctx.ensure_object(dict) @cli.group() def costs(): pass @costs.command("summary") @click.option("--provider", help="aws | gcp | azure | k8s") @click.pass_context def costs_summary(ctx, provider): try: l4 = make_client(ctx) if provider: data = l4.costs.get_provider_summary(provider) else: data = l4.costs.get_summary() click.echo(f"Monthly spend: ${data.monthly_spending:,.2f}") click.echo(f"Forecast: ${data.forecasted_monthly_costs:,.2f}") click.echo(f"Potential savings: ${data.potential_savings:,.2f}") except AuthenticationError: click.echo("Auth required. Set LEVELFOUR_API_KEY.", err=True) sys.exit(4) except RateLimitError as e: click.echo(f"Rate limited; retry after {e.retry_after}s", err=True) sys.exit(2) except NotFoundError as e: click.echo(f"Not found: {e.message}", err=True) sys.exit(1) except LevelFourError as e: click.echo(f"LevelFour error {e.status_code}: {e.message}", err=True) sys.exit(1) @cli.group() def recs(): pass @recs.command("list") @click.option("--status", multiple=True) @click.option("--service", multiple=True) @click.pass_context def recs_list(ctx, status, service): l4 = make_client(ctx) for rec in l4.recommendations.list( page_size=100, display_status=list(status) or None, service=list(service) or None, ): click.echo(f"{rec.recommendation_id}\t${rec.monthly_savings:.2f}/mo\t{rec.service}") if __name__ == "__main__": cli() ``` > **Note** > > The SDK's `SyncPager` fetches every page as you iterate, so `page_size` sizes each request and does not cap what comes back. You do not need a manual `for page in pages` loop. See [Pagination](/pagination). **TypeScript (Commander)** ```bash npm install levelfour commander ``` ```typescript title="cli.ts" import { Command } from "commander"; import { LevelFourClient, AuthenticationError, RateLimitError, NotFoundError, LevelFourError, } from "levelfour"; const program = new Command(); let client: LevelFourClient | null = null; function getClient() { if (!client) client = new LevelFourClient(); return client; } function exitOnError(err: unknown): never { if (err instanceof AuthenticationError) { console.error("Auth required. Set LEVELFOUR_API_KEY."); process.exit(4); } if (err instanceof RateLimitError) { console.error(`Rate limited; retry after ${err.retryAfter}s`); process.exit(2); } if (err instanceof NotFoundError) { console.error(`Not found: ${err.message}`); process.exit(1); } if (err instanceof LevelFourError) { console.error(`LevelFour error ${err.statusCode}: ${err.message}`); process.exit(1); } throw err; } const costs = program.command("costs"); costs .command("summary") .option("--provider ", "aws | gcp | azure | k8s") .action(async (opts) => { try { const l4 = getClient(); const data = opts.provider ? await l4.costs.getProviderSummary({ provider_id: opts.provider }) : await l4.costs.getSummary(); console.log(`Monthly spend: $${data.monthly_spending.toLocaleString()}`); console.log(`Forecast: $${data.forecasted_monthly_costs.toLocaleString()}`); console.log(`Potential savings: $${data.potential_savings.toLocaleString()}`); } catch (e) { exitOnError(e); } }); const recs = program.command("recs"); recs .command("list") .option("--status ") .option("--service ") .action(async (opts) => { try { const l4 = getClient(); for await (const rec of await l4.recommendations.list({ page_size: 100, display_status: opts.status, service: opts.service, })) { console.log(`${rec.recommendation_id}\t$${rec.monthly_savings.toFixed(2)}/mo\t${rec.service}`); } } catch (e) { exitOnError(e); } }); await program.parseAsync(); ``` **Go (Cobra)** ```bash go get github.com/LevelFourAI/levelfour-go github.com/spf13/cobra ``` The Go sample builds `costs-summary` only. The Python and TypeScript tabs also show `recs list` and the `--provider` option. ```go title="main.go" package main import ( "context" "errors" "fmt" "os" "github.com/LevelFourAI/levelfour-go/levelfour" "github.com/spf13/cobra" ) var l4 *levelfour.Client func makeClient() (*levelfour.Client, error) { if l4 != nil { return l4, nil } c, err := levelfour.NewClient("") if err != nil { return nil, err } l4 = c return l4, nil } func exitOnError(err error) { var authErr *levelfour.UnauthorizedError var rateErr *levelfour.TooManyRequestsError var notFoundErr *levelfour.NotFoundError switch { case errors.As(err, &authErr): fmt.Fprintln(os.Stderr, "Auth required. Set LEVELFOUR_API_KEY.") os.Exit(4) case errors.As(err, &rateErr): fmt.Fprintln(os.Stderr, "Rate limited") os.Exit(2) case errors.As(err, ¬FoundErr): fmt.Fprintln(os.Stderr, "Not found") os.Exit(1) default: fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func main() { root := &cobra.Command{Use: "acme"} costsSummary := &cobra.Command{ Use: "costs-summary", RunE: func(cmd *cobra.Command, args []string) error { client, err := makeClient() if err != nil { exitOnError(err) } data, err := client.Costs.GetSummary(context.Background()) if err != nil { exitOnError(err) } fmt.Printf("Monthly spend: $%.2f\n", data.MonthlySpending) return nil }, } root.AddCommand(costsSummary) if err := root.Execute(); err != nil { os.Exit(1) } } ``` `levelfour.PageIterator` and `levelfour.CollectAll` handle pagination. See [Pagination](/pagination). ## Authentication Your CLI does not need a credential layer of its own. If your team already injects an API key from `~/.acme/credentials` or a secret manager, export it as `LEVELFOUR_API_KEY` from that source and every SDK picks it up. [Authentication](/authentication) covers the key formats and the other ways to pass one. > **Note** > > Export the key from that source before your CLI constructs the client. ## Embed or shell out | Concern | Embed the SDK | Shell out to `l4` | | ------------------------------------- | -------------------------------------- | ------------------------------------------ | | Adds a dependency | Yes (PyPI / npm / go get) | No (a single static binary) | | Typed objects | Yes (Pydantic / TS types / Go structs) | No (you parse JSON) | | Control over filtering and pagination | Yes, the full SDK surface | Yes, via `l4` flags and jq | | Works in any language | No (Python, TypeScript and Go only) | Yes (anything that can `exec`) | | Adds a binary install step in CI | No (the package manager handles it) | Yes (`brew install` or a release download) | | Keeps PR diffs language-native | Yes | Mixes shell into your codebase | | Staying current | Lockfile-controlled | `l4` self-checks for updates | Embed the SDK when your CLI is in one of those three languages and you want first-class types. Shell out to `l4` when you are scripting in bash or Make, when your CLI is in some other language, or when you do not want the dependency. --- # Integrate l4 into your CLI Source: https://docs.levelfour.ai/guides/integrate-l4-into-your-cli.md Any script, Makefile or CI runner can read LevelFour data by shelling out to the `l4` binary, with no SDK in its dependency tree. `--json`, `--jq`, `--template` and `--quiet` give it machine-readable output, and the [exit codes](/cli#exit-codes) are stable enough to branch on. For typed objects in Python, TypeScript or Go, see [Integrate the SDK into your CLI](/guides/integrate-sdk-into-your-cli). ## Wire it into a runner ### Install the binary Put `l4` on the runner. [Installation](/cli/installation) covers Homebrew, `go install`, the release tarballs, and pinning a version inside a CI image. ### Authenticate from a secret `LEVELFOUR_TOKEN` bypasses the keychain, so a runner needs no interactive `l4 auth login` and nothing lands on the filesystem: ```bash export LEVELFOUR_TOKEN="$LEVELFOUR_TOKEN" # from a CI secret l4 whoami # smoke test ``` [Authentication](/cli/authentication) has the resolution order and the read versus read-write split. > **Success** > > A `whoami` that names your identity and organization is the proof the secret reached the runner. Exit `4` means no credential resolved at all. ### Ask for machine-readable output `--json` prints the same envelope the API returns. `--jq` filters it in process, so a runner with no `jq` binary still works: ```bash HIGH_VALUE_RECS=$(l4 recommendations list \ --status available \ --sort-by monthly_savings --sort-order desc \ --jq '.data.data.items[0:5] | map(.recommendation_id)') echo "$HIGH_VALUE_RECS" ``` [Output formats](/cli/output-formats) has the rest of the flags, and [Recipes](/cli/recipes) has the multi-stage pipelines. ### Branch on the exit code With `-q` the exit code is the only signal, which makes any command a boolean: ```bash if l4 status -q; then echo "API healthy, proceeding" else echo "API down, aborting" exit 1 fi ``` A cost gate on Terraform or CloudFormation wants the three-way version: ```bash l4 diff --base main --fail-above 100 -q ./infra/ case $? in 0) echo "No cost impact" ;; 2) echo "Over budget by more than \$100/mo"; exit 1 ;; *) echo "l4 diff failed"; exit 1 ;; esac ``` > **Warning** > > Exit `2` is `ExitIssuesFound`, not a crash. A script that treats every non-zero exit as a failure reports an over-budget cost gate and a broken command identically. Branch on the code. [Exit codes](/cli#exit-codes) lists them all. ## Wrap it from your own tooling **Bash** Pull a cost summary into shell variables: ```bash title="cost-summary.sh" #!/usr/bin/env bash set -euo pipefail eval "$( l4 costs summary --provider aws --json \ | jq -r '.data | "MONTHLY=\(.monthly_spending)\nFORECAST=\(.forecasted_monthly_costs)\nSAVINGS=\(.potential_savings)"' )" echo "AWS spend this month: \$$MONTHLY" echo "Forecasted: \$$FORECAST" echo "Potential savings: \$$SAVINGS" ``` **Make** A cost gate and a monthly export, as targets: ```make title="Makefile" .PHONY: cost-check cost-check: @l4 diff --base main --fail-above 100 -q ./infra/ \ || { echo "Cost gate failed"; exit 1; } .PHONY: weekly-report weekly-report: @l4 export costs --period $$(date -u +%Y-%m) --format csv > "costs-$$(date -u +%Y-%m).csv" @l4 export recommendations --format csv > "recs-$$(date +%Y%m%d).csv" ``` **Python** Wrap the binary in a function and parse `--json`: ```python title="l4_wrapper.py" import json import subprocess def l4(*args): result = subprocess.run( ["l4", *args, "--json"], capture_output=True, text=True, check=True, ) return json.loads(result.stdout) summary = l4("costs", "summary", "--provider", "aws") print(f"Spend: ${summary['data']['monthly_spending']:,.2f}") ``` ## Vendor the binary into a CI image `l4` is a single static Go binary, so an image needs no runtime libraries: ```dockerfile title="Dockerfile" FROM alpine:3 RUN apk add --no-cache ca-certificates curl \ && curl -fsSL https://github.com/LevelFourAI/levelfour-cli/releases/latest/download/levelfour_latest_linux_amd64.tar.gz \ | tar xz -C /usr/local/bin levelfour l4 \ && chmod +x /usr/local/bin/l4 /usr/local/bin/levelfour ENV LEVELFOUR_TOKEN="" ``` > **Warning** > > Leave `LEVELFOUR_TOKEN` empty in the image. An `ENV` value is baked into the layers and ships with them, so inject the token at run time from your CI secret store. [Installation](/cli/installation) pins a version instead of tracking the latest release. For a complete GitHub Actions or GitLab job built on this binary, see [CI/CD Integration](/guides/ci-cd). ## Shell out or embed Reach for the binary in scripts, Makefiles, CI runners and polyglot tooling. Reach for the SDK inside an internal CLI that wants native types. Start with `l4` when you are unsure: it integrates fastest, and the SDK path stays open. The binary reads `LEVELFOUR_TOKEN`, the SDKs read `LEVELFOUR_API_KEY`. Shelling out also gets the CLI's own argument validation, output formatting and TUI for nothing, and an embedded SDK has none of it. [Integrate the SDK into your CLI](/guides/integrate-sdk-into-your-cli) compares the two in full. --- # Backstage Plugin Source: https://docs.levelfour.ai/guides/backstage-plugin.md A [Backstage](https://backstage.io/) plugin that puts LevelFour cost data in your developer portal. You build both halves: * **Backend plugin** - Express router that wraps `LevelFourClient` from the `levelfour` npm package * **Frontend plugin** - React component that fetches data from the backend and renders it The backend uses Backstage's new backend system (`createBackendPlugin`). The frontend reaches it through `createApiFactory` and `discoveryApi`. ## Backend plugin ### Add your API key to the app config Add your LevelFour API key to `app-config.yaml`. [Create one](/authentication#api-keys) if you do not have a key yet. ```yaml title="app-config.yaml" levelfour: apiKey: ${LEVELFOUR_API_KEY} ``` > **Tip** > > The router only reads, so a `read` key is enough. [Key scopes](/authentication#key-scopes) has the difference. ### Register the router with core services The plugin entry point registers the HTTP router with Backstage's core services: ```typescript title="plugins/levelfour-backend/src/plugin.ts" import { createBackendPlugin, coreServices, } from "@backstage/backend-plugin-api"; import { createRouter } from "./router"; export const levelfourPlugin = createBackendPlugin({ pluginId: "levelfour", register(env) { env.registerInit({ deps: { httpRouter: coreServices.httpRouter, logger: coreServices.logger, config: coreServices.rootConfig, }, async init({ httpRouter, logger, config }) { const router = await createRouter({ logger, config }); httpRouter.use(router); }, }); }, }); ``` ### Wrap the SDK in an Express router The router reads the API key from config, creates a [`LevelFourClient`](/sdks/typescript), and exposes endpoints at `/api/levelfour/*`: ```typescript title="plugins/levelfour-backend/src/router.ts" import { Router } from "express"; import { LevelFourClient, LevelFourError } from "levelfour"; import type { LoggerService } from "@backstage/backend-plugin-api"; import type { Config } from "@backstage/config"; interface RouterOptions { logger: LoggerService; config: Config; } export async function createRouter(options: RouterOptions): Promise { const { logger, config } = options; const router = Router(); const apiKey = config.getOptionalString("levelfour.apiKey"); if (!apiKey) { logger.warn("levelfour.apiKey not configured in app-config.yaml"); } const client = new LevelFourClient({ apiKey }); router.get("/health", (_req, res) => { res.json({ status: "ok" }); }); router.get("/recommendations/summary", async (_req, res) => { try { const data = await client.recommendations.getSavingsByProvider(); res.json(data); } catch (err) { if (err instanceof LevelFourError) { logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`); res.status(err.statusCode ?? 500).json({ error: err.message }); return; } throw err; } }); router.get("/costs/summary", async (_req, res) => { try { const data = await client.costs.getSummary(); res.json(data); } catch (err) { if (err instanceof LevelFourError) { logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`); res.status(err.statusCode ?? 500).json({ error: err.message }); return; } throw err; } }); router.get("/recommendations/overview", async (_req, res) => { try { const data = await client.recommendations.getOverview(); res.json(data); } catch (err) { if (err instanceof LevelFourError) { logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`); res.status(err.statusCode ?? 500).json({ error: err.message }); return; } throw err; } }); return router; } ``` > **Note** > > A missing `levelfour.apiKey` logs a warning and the plugin starts anyway. The router builds the client without an explicit key, so nothing fails at startup and any gap shows up later, on a request that reaches the LevelFour API. ### Declare the backend dependencies ```json title="plugins/levelfour-backend/package.json" { "dependencies": { "@backstage/backend-plugin-api": "^1.3.0", "express": "^4.21.0", "levelfour": "^0.1.0" } } ``` ### Add the plugin to your backend In your Backstage backend's `src/index.ts`: ```typescript backend.add(import("@internal/plugin-levelfour-backend")); ``` ## Frontend plugin ### Define the API client The frontend talks to the backend plugin through a typed client: ```typescript title="plugins/levelfour/src/api.ts" import { createApiRef, type DiscoveryApi, type FetchApi, } from "@backstage/core-plugin-api"; export interface LevelFourApi { getRecommendationsSummary(): Promise; getCostsSummary(): Promise; getRecommendationsOverview(): Promise; } export const levelFourApiRef = createApiRef({ id: "plugin.levelfour.service", }); export class LevelFourApiClient implements LevelFourApi { private readonly discoveryApi: DiscoveryApi; private readonly fetchApi: FetchApi; constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) { this.discoveryApi = options.discoveryApi; this.fetchApi = options.fetchApi; } private async fetch(path: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl("levelfour"); const response = await this.fetchApi.fetch(`${baseUrl}${path}`); if (!response.ok) { throw new Error(`LevelFour API error: ${response.status} ${response.statusText}`); } return response.json(); } async getRecommendationsSummary(): Promise { return this.fetch("/recommendations/summary"); } async getCostsSummary(): Promise { return this.fetch("/costs/summary"); } async getRecommendationsOverview(): Promise { return this.fetch("/recommendations/overview"); } } ``` > **Warning** > > `getBaseUrl("levelfour")` has to match the backend plugin's `pluginId`, which is what puts the routes at `/api/levelfour/*`. Change one without the other and the dashboard fetches a URL that looks right and gets a 404 back. ### Create the plugin and its page Register the API factory and routable extension: ```typescript title="plugins/levelfour/src/plugin.ts" import { createPlugin, createApiFactory, createRoutableExtension, discoveryApiRef, fetchApiRef, } from "@backstage/core-plugin-api"; import { levelFourApiRef, LevelFourApiClient } from "./api"; import { rootRouteRef } from "./routes"; export const levelfourPlugin = createPlugin({ id: "levelfour", apis: [ createApiFactory({ api: levelFourApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory: ({ discoveryApi, fetchApi }) => new LevelFourApiClient({ discoveryApi, fetchApi }), }), ], routes: { root: rootRouteRef, }, }); export const LevelFourPage = levelfourPlugin.provide( createRoutableExtension({ name: "LevelFourPage", component: () => import("./components/CostDashboard").then((m) => m.CostDashboard), mountPoint: rootRouteRef, }), ); ``` ### Render the dashboard The dashboard component fetches recommendations and cost data in parallel: ```tsx title="plugins/levelfour/src/components/CostDashboard.tsx" import React, { useEffect, useState } from "react"; import { useApi } from "@backstage/core-plugin-api"; import { Header, Page, Content, InfoCard, Progress, } from "@backstage/core-components"; import { levelFourApiRef } from "../api"; export const CostDashboard = () => { const api = useApi(levelFourApiRef); const [recommendations, setRecommendations] = useState(null); const [costs, setCosts] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { Promise.all([ api.getRecommendationsSummary(), api.getCostsSummary(), ]) .then(([recs, costs]) => { setRecommendations(recs); setCosts(costs); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); }, [api]); if (loading) return ; if (error) { return (
{error} ); } return (
{JSON.stringify(recommendations, null, 2)}
{JSON.stringify(costs, null, 2)}
); }; ``` ### Add the route to your app In your Backstage app's `App.tsx`, add the route: ```tsx title="App.tsx" import { LevelFourPage } from "@internal/plugin-levelfour"; } /> ``` ## Verify it works With both plugins registered, check the backend first: ```bash curl http://localhost:7007/api/levelfour/health curl http://localhost:7007/api/levelfour/recommendations/summary curl http://localhost:7007/api/levelfour/costs/summary ``` > **Success** > > `/health` answering `{ "status": "ok" }` means the router is mounted. The other two go out to the LevelFour API, so JSON back from `/recommendations/summary` is the whole path working, config through SDK. > **Error** > > A body of `{ "error": ... }` is the LevelFour API's own failure rather than a Backstage one. The router forwards it with `res.status(err.statusCode ?? 500)`, so you get the upstream status, or a 500 when the error carries none. On a 4xx, check the key and the account. [Error handling](/error-handling) has the class behind it. Then open `http://localhost:3000/levelfour` to see the cost dashboard. ## Next * [TypeScript SDK](/sdks/typescript) is the `levelfour` package the router imports: installing it, constructing the client and the constructor options * [Resources](/resources) has every method the router calls, with its parameters and response shapes * [Authentication](/authentication) is where the key in `app-config.yaml` comes from, and what a `read` scope covers * [Error handling](/error-handling) is the hierarchy behind `LevelFourError` and its `statusCode` * [Backstage Cost Insights plugin](https://github.com/backstage/community-plugins/tree/main/workspaces/cost-insights) follows the same patterns. This one calls the LevelFour API through the `levelfour` npm package. --- # Slack Integration Source: https://docs.levelfour.ai/guides/slack-integration.md LevelFour signs every webhook it sends. This receiver checks the signature, turns the event into Slack blocks, and posts it to a channel. [Webhooks](/webhooks) owns the headers, the signature algorithm, the timestamp tolerance and the retry rules the code below depends on. ## What lands in the channel Every message opens with the event name, then carries the recommendation id and the status. The event decides what goes underneath. | Event | What the message adds | | ------------------------- | ------------------------- | | `recommendation.accepted` | Who accepted it | | `recommendation.rejected` | The rejection reason | | `optimization.started` | The implementation method | | `optimization.completed` | Nothing beyond the status | | `optimization.failed` | A warning line | [Events](/webhooks#events) has what each one means, and [Payloads](/webhooks#payloads) has the fields each carries. ## Build it ### Create the Slack app Create an app at [api.slack.com/apps](https://api.slack.com/apps) with the `chat:write` scope, then copy its Bot User OAuth Token. It starts with `xoxb-`. > **Note** > > Invite the bot to the target channel before the first event arrives. The receiver posts to `SLACK_CHANNEL`, and the bot has to be in it. ### Set the environment The receiver reads its configuration from the environment. | Variable | Description | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `LEVELFOUR_WEBHOOK_SECRET` | Webhook signing secret from LevelFour (`whsec_...`), the one described in [Verify the signature](/webhooks#verify-the-signature) | | `SLACK_BOT_TOKEN` | Slack Bot User OAuth Token (`xoxb-...`) | | `SLACK_CHANNEL` | Target Slack channel (default: `#cloud-costs`) | | `PORT` | Port the server listens on (default: `3000`), read by the TypeScript version only | ### Write the receiver Pick a language. The Python receiver is FastAPI, the TypeScript one Express, and both do the same three things: install the dependencies, save the server file, and run it. **Python** ```bash pip install levelfour fastapi uvicorn httpx ``` ```python title="main.py" import os import httpx from fastapi import FastAPI, Request, Response from levelfour.webhooks.verifier import WebhookVerificationError, WebhookVerifier app = FastAPI() WEBHOOK_SECRET = os.environ["LEVELFOUR_WEBHOOK_SECRET"] SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"] SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "#cloud-costs") verifier = WebhookVerifier(WEBHOOK_SECRET) EVENT_LABELS = { "recommendation.accepted": "Recommendation Accepted", "recommendation.rejected": "Recommendation Rejected", "optimization.started": "Optimization Started", "optimization.completed": "Optimization Completed", "optimization.failed": "Optimization Failed", } def build_slack_blocks(event_type: str, payload: dict) -> list[dict]: label = EVENT_LABELS.get(event_type, event_type) rec_id = payload.get("recommendation_id", "unknown") status = payload.get("status", "unknown") blocks = [ { "type": "header", "text": {"type": "plain_text", "text": f"LevelFour: {label}"}, }, { "type": "section", "fields": [ {"type": "mrkdwn", "text": f"*Recommendation:*\n{rec_id}"}, {"type": "mrkdwn", "text": f"*Status:*\n{status}"}, ], }, ] if event_type == "recommendation.accepted": accepted_by = payload.get("saving_accepted_by", "unknown") blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": f"Accepted by {accepted_by}"}], }) if event_type == "recommendation.rejected": reason = payload.get("rejection_reason", "No reason provided") blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": f"Reason: {reason}"}], }) if event_type == "optimization.started": method = payload.get("implementation_method", "unknown") blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": f"Method: {method}"}], }) if event_type == "optimization.failed": blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": ":warning: Optimization failed"}], }) return blocks async def post_to_slack(blocks: list[dict]) -> None: async with httpx.AsyncClient() as client: await client.post( "https://slack.com/api/chat.postMessage", headers={"Authorization": f"Bearer {SLACK_BOT_TOKEN}"}, json={"channel": SLACK_CHANNEL, "blocks": blocks}, ) @app.post("/webhook") async def handle_webhook(request: Request) -> Response: body = await request.body() headers = { "webhook-id": request.headers.get("webhook-id", ""), "webhook-timestamp": request.headers.get("webhook-timestamp", ""), "webhook-signature": request.headers.get("webhook-signature", ""), } try: payload = verifier.verify(payload=body, headers=headers) except WebhookVerificationError: return Response(status_code=400, content="Invalid signature") event_type = payload.get("type", "") blocks = build_slack_blocks(event_type, payload) await post_to_slack(blocks) return Response(status_code=200, content="OK") ``` Run it with the variables in the environment: ```bash LEVELFOUR_WEBHOOK_SECRET="whsec_..." \ SLACK_BOT_TOKEN="xoxb-..." \ SLACK_CHANNEL="#cloud-costs" \ uvicorn main:app --port 8000 ``` **TypeScript** ```bash npm install levelfour express @slack/web-api ``` ```typescript title="server.ts" import express from "express"; import { WebClient, type KnownBlock } from "@slack/web-api"; import { WebhookVerifier, WebhookVerificationError } from "levelfour"; const WEBHOOK_SECRET = process.env.LEVELFOUR_WEBHOOK_SECRET!; const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN!; const SLACK_CHANNEL = process.env.SLACK_CHANNEL || "#cloud-costs"; const PORT = parseInt(process.env.PORT || "3000", 10); const verifier = new WebhookVerifier(WEBHOOK_SECRET); const slack = new WebClient(SLACK_BOT_TOKEN); const EVENT_LABELS: Record = { "recommendation.accepted": "Recommendation Accepted", "recommendation.rejected": "Recommendation Rejected", "optimization.started": "Optimization Started", "optimization.completed": "Optimization Completed", "optimization.failed": "Optimization Failed", }; interface WebhookPayload { type?: string; recommendation_id?: string; status?: string; saving_accepted_by?: string; rejection_reason?: string; implementation_method?: string; [key: string]: unknown; } function buildSlackBlocks(eventType: string, payload: WebhookPayload) { const label = EVENT_LABELS[eventType] || eventType; const recId = payload.recommendation_id || "unknown"; const status = payload.status || "unknown"; const blocks: KnownBlock[] = [ { type: "header", text: { type: "plain_text", text: `LevelFour: ${label}` }, }, { type: "section", fields: [ { type: "mrkdwn", text: `*Recommendation:*\n${recId}` }, { type: "mrkdwn", text: `*Status:*\n${status}` }, ], }, ]; if (eventType === "recommendation.accepted" && payload.saving_accepted_by) { blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: `Accepted by ${payload.saving_accepted_by}` }], }); } if (eventType === "recommendation.rejected") { const reason = payload.rejection_reason || "No reason provided"; blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: `Reason: ${reason}` }], }); } if (eventType === "optimization.started" && payload.implementation_method) { blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: `Method: ${payload.implementation_method}` }], }); } if (eventType === "optimization.failed") { blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: ":warning: Optimization failed" }], }); } return blocks; } const app = express(); app.use(express.raw({ type: "application/json" })); app.post("/webhook", async (req, res) => { const body = req.body as Buffer; const headers: Record = { "webhook-id": req.headers["webhook-id"] as string || "", "webhook-timestamp": req.headers["webhook-timestamp"] as string || "", "webhook-signature": req.headers["webhook-signature"] as string || "", }; let payload: WebhookPayload; try { payload = verifier.verify(body, headers) as WebhookPayload; } catch (err) { if (err instanceof WebhookVerificationError) { res.status(400).send("Invalid signature"); return; } throw err; } const eventType = payload.type || "unknown"; const blocks = buildSlackBlocks(eventType, payload); await slack.chat.postMessage({ channel: SLACK_CHANNEL, blocks: blocks, }); res.status(200).send("OK"); }); app.listen(PORT, () => { console.log(`Listening on port ${PORT}`); }); ``` Run it the same way, with `PORT` added: ```bash LEVELFOUR_WEBHOOK_SECRET="whsec_..." \ SLACK_BOT_TOKEN="xoxb-..." \ SLACK_CHANNEL="#cloud-costs" \ PORT=3000 \ npx tsx server.ts ``` > **Warning** > > Verify the bytes as they arrived. The signature covers the exact body LevelFour sent, so `express.raw` has to stay: swap in `express.json()` and every event fails verification. `await request.body()` is the FastAPI equivalent. > **Error** > > A 400 from this endpoint means the signature did not verify. Check the `whsec_` value first, then whether the delivery sat longer than the verifier's timestamp tolerance. [Verify the signature](/webhooks#verify-the-signature) has the headers and the algorithm. ### Expose it and register the URL LevelFour posts to a URL it can reach, so the receiver needs a public one: a cloud function, a container, or an ngrok tunnel while you are testing. Register that URL: **Python** ```python from levelfour import LevelFour client = LevelFour() client.webhooks.register( url="https://your-domain.com/webhook", event_types=[ "recommendation.accepted", "recommendation.rejected", "optimization.started", "optimization.completed", "optimization.failed", ], ) ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); await client.webhooks.register({ url: "https://your-domain.com/webhook", event_types: [ "recommendation.accepted", "recommendation.rejected", "optimization.started", "optimization.completed", "optimization.failed", ], }); ``` [Register an endpoint](/webhooks#register-an-endpoint) has the full parameter list. > **Success** > > A verified `recommendation.accepted` event posts a message headed **LevelFour: Recommendation Accepted**, with the recommendation id and the status in the fields beneath it. That message proves both secrets: the signature verified against `LEVELFOUR_WEBHOOK_SECRET`, and Slack accepted the post with `SLACK_BOT_TOKEN`. > **Warning** > > This handler posts to Slack inside the request and reads `webhook-id` only to verify the signature, never to deduplicate on it, so a slow post means a retry and a retry means the same event in the channel twice. Before you rely on it, deduplicate on `webhook-id` and move the Slack post off the request. See [Retries](/webhooks#retries). ## Next * [Webhooks](/webhooks) is the reference: payloads, retries, timestamp tolerance and the signing algorithm * [Google Chat integration](/guides/google-chat-integration) is the same receiver against a Chat space --- # Google Chat Integration Source: https://docs.levelfour.ai/guides/google-chat-integration.md A Google Chat incoming webhook takes a JSON payload over HTTP POST at a URL. There is no OAuth flow and no bot to install, so the receiver stays small: verify the LevelFour signature, shape the event into a message, post it to the URL. [Webhooks](/webhooks) owns the delivery model, the event payloads and the signing details this receiver depends on. ## Environment variables | Variable | Description | | -------------------------- | --------------------------------------------------- | | `LEVELFOUR_WEBHOOK_SECRET` | Webhook signing secret from LevelFour (`whsec_...`) | | `GOOGLE_CHAT_WEBHOOK_URL` | Google Chat incoming webhook URL | ## Set it up ### Create the Google Chat webhook 1. Open the Google Chat space where you want notifications 2. Click the space name at the top, then **Apps & integrations** 3. Click **Add webhooks** 4. Name it, "LevelFour Costs" for example. The avatar URL is optional 5. Copy the webhook URL. It looks like `https://chat.googleapis.com/v1/spaces/...` That URL is what `GOOGLE_CHAT_WEBHOOK_URL` holds. > **Warning** > > The Chat webhook URL is the credential. Nothing sits behind it, no OAuth and no bot, so anyone holding it can post into the space. Keep it in an environment variable or a secrets manager, never in a committed file. ### Build the receiver The receiver verifies the signature, builds a Chat message from the event, and posts it to the space. **Python (FastAPI)** ```bash pip install levelfour fastapi uvicorn httpx ``` ```python title="main.py" import os import httpx from fastapi import FastAPI, Request, Response from levelfour.webhooks.verifier import WebhookVerificationError, WebhookVerifier app = FastAPI() WEBHOOK_SECRET = os.environ["LEVELFOUR_WEBHOOK_SECRET"] GOOGLE_CHAT_WEBHOOK_URL = os.environ["GOOGLE_CHAT_WEBHOOK_URL"] verifier = WebhookVerifier(WEBHOOK_SECRET) EVENT_LABELS = { "recommendation.accepted": "Recommendation Accepted", "recommendation.rejected": "Recommendation Rejected", "optimization.started": "Optimization Started", "optimization.completed": "Optimization Completed", "optimization.failed": "Optimization Failed", } def build_chat_message(event_type: str, payload: dict) -> dict: label = EVENT_LABELS.get(event_type, event_type) rec_id = payload.get("recommendation_id", "unknown") status = payload.get("status", "unknown") text = f"*LevelFour: {label}*\nRecommendation: `{rec_id}`\nStatus: `{status}`" if event_type == "recommendation.accepted": accepted_by = payload.get("saving_accepted_by", "unknown") text += f"\nAccepted by: {accepted_by}" if event_type == "recommendation.rejected": reason = payload.get("rejection_reason", "No reason provided") text += f"\nReason: {reason}" if event_type == "optimization.started": method = payload.get("implementation_method", "unknown") text += f"\nMethod: {method}" if event_type == "optimization.failed": text += "\n⚠️ Optimization failed" return {"text": text} async def post_to_google_chat(message: dict) -> None: async with httpx.AsyncClient() as client: await client.post(GOOGLE_CHAT_WEBHOOK_URL, json=message) @app.post("/webhook") async def handle_webhook(request: Request) -> Response: body = await request.body() headers = { "webhook-id": request.headers.get("webhook-id", ""), "webhook-timestamp": request.headers.get("webhook-timestamp", ""), "webhook-signature": request.headers.get("webhook-signature", ""), } try: payload = verifier.verify(payload=body, headers=headers) except WebhookVerificationError: return Response(status_code=400, content="Invalid signature") event_type = payload.get("type", "") message = build_chat_message(event_type, payload) await post_to_google_chat(message) return Response(status_code=200, content="OK") ``` Run it with both variables in the environment: ```bash LEVELFOUR_WEBHOOK_SECRET="whsec_..." \ GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/..." \ uvicorn main:app --port 8000 ``` **TypeScript (Express)** ```bash npm install levelfour express ``` ```typescript title="server.ts" import express from "express"; import { WebhookVerifier, WebhookVerificationError } from "levelfour"; const WEBHOOK_SECRET = process.env.LEVELFOUR_WEBHOOK_SECRET!; const GOOGLE_CHAT_WEBHOOK_URL = process.env.GOOGLE_CHAT_WEBHOOK_URL!; const PORT = parseInt(process.env.PORT || "3000", 10); const verifier = new WebhookVerifier(WEBHOOK_SECRET); const EVENT_LABELS: Record = { "recommendation.accepted": "Recommendation Accepted", "recommendation.rejected": "Recommendation Rejected", "optimization.started": "Optimization Started", "optimization.completed": "Optimization Completed", "optimization.failed": "Optimization Failed", }; interface WebhookPayload { type?: string; recommendation_id?: string; status?: string; saving_accepted_by?: string; rejection_reason?: string; implementation_method?: string; [key: string]: unknown; } function buildChatMessage(eventType: string, payload: WebhookPayload): { text: string } { const label = EVENT_LABELS[eventType] || eventType; const recId = payload.recommendation_id || "unknown"; const status = payload.status || "unknown"; let text = `*LevelFour: ${label}*\nRecommendation: \`${recId}\`\nStatus: \`${status}\``; if (eventType === "recommendation.accepted" && payload.saving_accepted_by) { text += `\nAccepted by: ${payload.saving_accepted_by}`; } if (eventType === "recommendation.rejected") { const reason = payload.rejection_reason || "No reason provided"; text += `\nReason: ${reason}`; } if (eventType === "optimization.started" && payload.implementation_method) { text += `\nMethod: ${payload.implementation_method}`; } if (eventType === "optimization.failed") { text += "\n⚠️ Optimization failed"; } return { text }; } const app = express(); app.use(express.raw({ type: "application/json" })); app.post("/webhook", async (req, res) => { const body = req.body as Buffer; const headers: Record = { "webhook-id": req.headers["webhook-id"] as string || "", "webhook-timestamp": req.headers["webhook-timestamp"] as string || "", "webhook-signature": req.headers["webhook-signature"] as string || "", }; let payload: WebhookPayload; try { payload = verifier.verify(body, headers) as WebhookPayload; } catch (err) { if (err instanceof WebhookVerificationError) { res.status(400).send("Invalid signature"); return; } throw err; } const eventType = payload.type || "unknown"; const message = buildChatMessage(eventType, payload); await fetch(GOOGLE_CHAT_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message), }); res.status(200).send("OK"); }); app.listen(PORT, () => { console.log(`Listening on port ${PORT}`); }); ``` > **Warning** > > `express.raw` on this route is load-bearing. The signature covers the exact bytes LevelFour sent, so an `express.json()` in front of it hands the verifier a re-serialized body and every delivery fails, which reads exactly like a wrong signing secret. Run it with both variables in the environment: ```bash LEVELFOUR_WEBHOOK_SECRET="whsec_..." \ GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/..." \ PORT=3000 \ npx tsx server.ts ``` > **Error** > > A `400 Invalid signature` means the verifier rejected the request, so nothing reached Google Chat. Check that `LEVELFOUR_WEBHOOK_SECRET` holds the `whsec_` value for this endpoint, and that the verifier is reading the raw request body. [Webhooks](/webhooks) has the algorithm and the header names. ### Register the endpoint with LevelFour LevelFour delivers to a URL it can reach, so the receiver has to be live at a public HTTPS address before you register it. **Python** ```python from levelfour import LevelFour client = LevelFour() client.webhooks.register( url="https://your-domain.com/webhook", event_types=[ "recommendation.accepted", "recommendation.rejected", "optimization.started", "optimization.completed", "optimization.failed", ], ) ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); await client.webhooks.register({ url: "https://your-domain.com/webhook", event_types: [ "recommendation.accepted", "recommendation.rejected", "optimization.started", "optimization.completed", "optimization.failed", ], }); ``` > **Success** > > The next matching event posts a message into the space, headed **LevelFour: Recommendation Accepted** or whichever label fits. That message is the proof: the signature verified and the post went through. ## Next * [Webhooks](/webhooks) is the reference: payloads, retries, timestamp tolerance and the signing algorithm * [Slack Integration](/guides/slack-integration) is the same receiver against Slack * [Scheduled Execution](/guides/scheduled-execution) polls on a schedule instead of receiving events --- # Scheduled Execution Source: https://docs.levelfour.ai/guides/scheduled-execution.md A scheduled check tells you what moved since the last one: what is new in the backlog, what is executing, and what finished. Every call it makes reads, so a `read` key covers the whole job and nothing in your cloud changes. ## Run it on a schedule ### Write the check ```python title="monitor.py" from levelfour import LevelFour client = LevelFour() overview = client.recommendations.get_overview() print(overview) processing = client.recommendations.list_in_progress() print(processing) for rec in client.recommendations.list( page_size=50, sort_by="monthly_savings", sort_order="desc", ): print(f"{rec.service}: ${rec.monthly_savings}/mo") ``` Three calls: [Get overview](/resources/recommendations#get-overview) for the headline numbers, [List in-progress](/resources/recommendations#list-in-progress) for what is executing right now, and [List recommendations](/resources/recommendations#list-recommendations) for the backlog, largest saving first. The TypeScript and Go form of each one sits on that same page. ### Give it a key The SDK reads `LEVELFOUR_API_KEY` from the environment, so the client takes no arguments and the scheduler supplies the value. The crontab entry sets it inline. The workflow reads it from a repository secret of the same name. > **Warning** > > A key written inline in a crontab sits in plain text on the host, and a `read` key reaches every cost figure, recommendation and savings number in your organization. Keep it in a secrets manager or a file only the job's user can read. Give the schedule a `read` key, since it applies nothing. [Key scopes](/authentication#key-scopes) has the split. ### Point a scheduler at it **cron** Add the line with `crontab -e`. It runs at 06:00 in the host's timezone, Monday to Friday. ```bash 0 6 * * 1-5 LEVELFOUR_API_KEY=l4_live_... python monitor.py >> /var/log/levelfour.log 2>&1 ``` **GitHub Actions** Commit `monitor.py` next to the workflow, and store the key as a repository secret named `LEVELFOUR_API_KEY`. ```yaml title=".github/workflows/monitor-savings.yml" name: Monitor Savings on: schedule: - cron: '0 6 * * 1-5' jobs: monitor: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - run: pip install levelfour - name: Check recommendations env: LEVELFOUR_API_KEY: ${{ secrets.LEVELFOUR_API_KEY }} run: python monitor.py ``` GitHub schedules on UTC, so the same expression fires at 06:00 UTC rather than in your own zone. [GitHub Actions](/guides/github-actions) has three more workflows: a [scheduled cost check](/guides/github-actions#scheduled-cost-check) that runs the SDK inline rather than from a committed script, a cost summary commented on a pull request, and a daily report uploaded as a build artifact. ### Confirm the first run > **Success** > > The overview numbers in `/var/log/levelfour.log` after the scheduled minute are the proof: the entry fired and the key was accepted. On GitHub Actions the same output lands in the job log. The crontab entry redirects stderr to that file too, so a traceback there means the job ran and a call failed. An empty or missing file means the entry never fired, or the job cannot write to that path. Run `python monitor.py` by hand once before you schedule it, so a silent morning afterwards has only one place to hide. ## When to stop polling > **Warning** > > A poll sees only the state at the minute it runs. An optimization that starts and finishes between two runs never appears in `list_in_progress`, and the log reads like a quiet day. [Webhooks](/webhooks) deliver each change as it happens, covering acceptances, rejections and every stage of an optimization. [Events](/webhooks#events) has what each one means, and [Register an endpoint](/webhooks#register-an-endpoint) is the call that subscribes you. Once an endpoint is live the schedule has nothing left to do. [Slack Integration](/guides/slack-integration) and [Google Chat Integration](/guides/google-chat-integration) are complete receivers, signature verification included. ## Next * [Recommendations](/resources/recommendations) is every call the script makes, in Python, TypeScript and Go * [Webhooks](/webhooks) is the push alternative, with the payloads, the signing algorithm and the retry behavior * [GitHub Actions](/guides/github-actions) has the pull request comment, a scheduled check that runs the SDK inline, and the daily report artifact * [Authentication](/authentication#key-scopes) is which scope a scheduled key needs --- # Security and privacy Source: https://docs.levelfour.ai/account/security.md ## AI and your data * LevelFour does not use your data to train, fine-tune or otherwise improve AI models, ours or anyone else's. That covers data from your connected accounts, the questions you ask and the answers you get. * Our agreements with the AI providers we work with forbid them from training on your data. * AI features see only your organization's data, and only what the person asking is allowed to see. * We will change this only if your organization opts in, in writing. ## Connecting your accounts You grant LevelFour access through each provider's own access controls: an IAM role on AWS, a service account on Google Cloud, Azure Lighthouse on Azure, or an API key or token you create on other providers. You can revoke it at any time, and LevelFour never asks for a password or a console login. * **Read access.** LevelFour reads billing data, resource configuration and usage metrics. Apart from the billing exports you set up for it, it does not read the contents of your storage, databases or application logs. * **Least privilege.** LevelFour asks for read-only access wherever the provider offers it. If a provider offers no read-only key, its connector page says so and lists what else the key can do. * **Changes.** Other than setting up the cost exports and cost services it reads, LevelFour changes resources in your account only after an admin in your organization approves the change. Each [connector's page](/connectors) lists the exact permissions it needs. ## Product and data security * **Single sign-on.** Sign in through Google Workspace, Microsoft Entra ID, Okta or any SAML 2.0 identity provider with [Enterprise SSO](/account/sso). * **Role-based access control.** Viewers get read-only access, which suits contractors and analysts. Members prepare savings and edit boards. Only admins approve a change or manage the organization. LevelFour enforces [roles](/account/members-and-roles) on every request, whether it comes from the dashboard, the API, the CLI or an AI client. * **API keys.** Admins create [API keys](/account/api-keys) as read-only or read & write, can set them to expire, and can revoke them at any time. * **Audit trail.** LevelFour logs every decision on a saving: who made it, when, and from where. * **Internal access.** A small group of LevelFour engineers can access production, only to run and support the service. They connect through AWS Systems Manager, with no inbound ports open to the internet. * **Network protection.** The API runs in private subnets of an AWS VPC, and the database has no public endpoint. * **Encryption.** TLS in transit and AES-256 at rest. LevelFour encrypts the keys and tokens you give it with AWS KMS, and stores its own API keys as one-way hashes. * **Isolation.** Each organization's cost and savings data lives in its own database. * **Hosting.** Amazon Web Services in the United States (`us-east-1`), with automated backups. * **Data retention.** LevelFour keeps your data while your organization uses it. To leave, revoke access in each provider and ask us to delete your data. We delete it within 30 days of your written request. ## Security reviews Email [security@levelfour.ai](mailto:security@levelfour.ai) to get our list of subprocessors, request a data processing agreement, or send us your security questionnaire. If an incident affects your data, we notify your organization's admins without undue delay. ## Vulnerability disclosure policy ### Overview If you find a security issue in LevelFour, tell us. We review and triage every report and fix the issues we confirm. We do not pay bounties. ### Scope In scope: * `levelfour.ai` and its subdomains, including `dashboard.levelfour.ai`, `docs.levelfour.ai` and `api.levelfour.ai` * The LevelFour REST API and MCP server Report issues that affect confidentiality, integrity or availability, such as: * authentication or access control flaws, including any way to reach another organization's data * injection or remote code execution * cross-site scripting (XSS) or cross-site request forgery (CSRF) * server-side request forgery (SSRF) * exposure of sensitive data * security misconfiguration Out of scope: * typos, layout and other content issues * feature requests and UI suggestions * issues in third-party applications * denial of service and volume testing * social engineering of LevelFour staff or customers * spam or email deliverability ### How to report Email [security@levelfour.ai](mailto:security@levelfour.ai) with: * a description of the issue * the URL, page, API endpoint or asset it affects * the steps to reproduce it * screenshots, logs or proof-of-concept code that support it Do not include anyone else's data, or screenshots of an account that is not yours. ### Terms and expectations By sending a report, you agree that: * you did your research in good faith, on accounts you own, with as little disruption as possible * you will not publish the issue until we have had a reasonable chance to fix it We acknowledge each report as soon as we can and may ask you for more detail. --- # Members and roles Source: https://docs.levelfour.ai/account/members-and-roles.md Everyone in your LevelFour organization holds one of three roles. The role decides what they can change. Everyone can read everything. ## Roles | Role | What it adds | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Viewer** | Reads every board, cost page, saving, commitment and statement. Changes nothing. | | **Member** | Everything a viewer can do, plus preparing a saving and asking an admin to approve it, commenting on savings, and editing boards, tags, cost alerts and anomaly statuses. | | **Admin** | Everything a member can do, plus accepting and rejecting savings, approving a rollout someone else requested, renewing commitments, and managing members, API keys, integrations and billing. | A rollout that a member requests waits for an admin to approve it, so no single member can apply a change to your cloud account alone. [Approvals and rollout](/savings-recommendations/approvals-and-rollout) walks through that flow. > **Note** > > The dashboard hides or disables the controls a role cannot use, and LevelFour enforces the same rules on every request, so a hidden button is never the only thing standing in the way. ## See who has access In the dashboard sidebar, open **Settings**, then **Members**. The **Users & Access** page lists everyone in the organization with their role, their last activity and the date they joined. Search by name, or narrow the list with **Filter by role**. ## Change a role ### Open the member's row On **Settings > Members**, find the person. Only an admin sees the role control and the row actions. ### Pick the new role Choose **Admin**, **Member** or **Viewer** from the role control on their row. ### Confirm The dialog names the old and new role. Click **Change role**. The new access applies right away. ## Remove a member Open the row's actions and click **Remove member**, then confirm. The person loses access right away. ## Limits on role changes * You cannot change your own role or remove yourself. Ask another admin. * The last admin cannot be demoted or removed. Make someone else an admin first. ## Adding people The dashboard does not send invitations yet. To add someone to your organization, talk to your LevelFour contact. If your organization signs in through your own identity provider, [Enterprise SSO](/account/sso) covers how that works. ## Next * [Enterprise SSO (SAML)](/account/sso) signs your team in with your identity provider * [API keys](/account/api-keys) are managed by admins, on the same Settings page * [Approvals and rollout](/savings-recommendations/approvals-and-rollout) is where the member and admin roles meet --- # Enterprise SSO (SAML) Source: https://docs.levelfour.ai/account/sso.md Enterprise SSO lets your team sign in to the LevelFour dashboard with your organization's identity provider (IdP) over SAML 2.0, instead of individual email or social logins. Once enabled, LevelFour routes anyone with an email address on your verified domain to your IdP to authenticate. SSO is an enterprise feature. To enable it, contact your LevelFour representative or email [support@levelfour.ai](mailto:support@levelfour.ai) with your identity provider and the email domains your team signs in with. ## Supported identity providers * Google Workspace * Microsoft Entra ID (formerly Azure AD) * Okta * Any SAML 2.0-compatible identity provider ## How it works Setting up SAML SSO is a one-time exchange of configuration between LevelFour (the service provider) and your identity provider: 1. You tell LevelFour your IdP and email domain. 2. LevelFour creates a connection for your domain and sends you the **ACS URL** (Assertion Consumer Service URL) and the **Entity ID** (service provider identifier). 3. You create a SAML application in your IdP using those values and map a few user attributes. 4. You send back your IdP SSO URL, your IdP Entity ID, and your X.509 signing certificate, or the full IdP metadata XML. 5. LevelFour activates the connection and tests it with you. > **Note** > > Your ACS URL and Entity ID are unique to your organization and are shared with you privately during setup. They are not published here. LevelFour routes sign-ins by email domain, so your existing logins keep working until it activates the connection. ## Required attribute mapping Your IdP must send these attributes in the SAML assertion: | User attribute | App attribute name | Required | | -------------- | ------------------ | -------- | | Email address | `mail` | Yes | | First name | `firstName` | No | | Last name | `lastName` | No | > **Warning** > > Set the Name ID to the user's primary email and the Name ID format to **EMAIL**. The app attribute names must match the table exactly. ## Set up Google Workspace These steps use Google Workspace. ### Create the SAML app In the Google Admin console, go to **Apps > Web and mobile apps**, then click **Add app > Add custom SAML app**. Enter an app name (for example, "LevelFour") and click **Continue**. ### Save your IdP details On the **Google Identity Provider details** page, download the **IdP metadata**, or copy the **SSO URL** and **Entity ID** and download the **Certificate**. You will send these to LevelFour. Click **Continue**. ### Enter the service provider details In **Service Provider Details**, enter the **ACS URL** and **Entity ID** LevelFour sent you. Click **Continue**. ### Set the Name ID and map the attributes Set the **Name ID format** to **EMAIL** and the **Name ID** to **Basic Information > Primary email**. Under **Attributes**, click **Add mapping** and map each Google directory field to its app attribute name: * **Basic Information > Primary email** to `mail` * **Basic Information > First name** to `firstName` * **Basic Information > Last name** to `lastName` Click **Finish**. ### Turn the app on Open the app, go to **User access**, select **On for everyone**, and click **Save**. ### Send LevelFour your IdP details Send LevelFour the IdP metadata (or the SSO URL, Entity ID, and certificate) you saved from the **Google Identity Provider details** page. Once LevelFour receives your IdP details, it loads them into your connection, tests a sign-in with one user on your domain, then activates SSO for the whole domain. ## Other identity providers The flow is identical for other providers. Create a SAML application in your IdP using the ACS URL and Entity ID LevelFour sends you, apply the same [attribute mapping](#required-attribute-mapping), and return your IdP metadata. > **Note** > > Send your IdP metadata and signing certificate to your LevelFour representative through a secure channel, not a public forum. ## Next * [Members and roles](/account/members-and-roles) is what each person can do once they are signed in * [API keys](/account/api-keys) is how code and CI reach LevelFour without a person signing in --- # API keys Source: https://docs.levelfour.ai/account/api-keys.md An API key lets your own code, the [`l4` CLI](/cli) or a CI job reach the LevelFour API without a person signing in. Keys live in the dashboard under **Settings > API Keys**. > **Note** > > Creating, rotating and revoking keys takes the **Admin** role. [Members and roles](/account/members-and-roles) lists what each role can do. ## Create a key ### Open API Keys In the dashboard sidebar, open **Settings**, then **API Keys**, and click **Create API Key**. ### Name it and pick its access Give the key a name that says where it runs, such as `CI pipeline` or `Finance export`. Under **Access**, pick one: * **Read-only** fetches data and changes nothing. * **Read & write** can also record decisions, such as accepting a saving. Under **Expiration**, pick **No expiration**, **30 days**, **60 days** or **90 days**. A key minted for a short-lived integration should expire. ### Save the key Click **Add**. The **Save Your Key** dialog shows the full key once. > **Warning** > > Copy the key before you close the dialog. LevelFour never shows it again, and a lost key can only be replaced. Click **I Have Saved This Key** when it is stored. [Authentication](/authentication) covers what each access level reaches in the API, the key prefixes, and where the SDKs and the CLI look for a key. ## Find a key The list shows each key's name, the start of its value, its access, its status, when it was last used, when it ends, and who created it. Search by name, or use the status filter to show **Active**, **Revoked** or **All** keys. A key that shows **Never** under **Last Used** has not made a request yet. ## Rotate a key Open the key's row actions and click **Rotate Key**, then confirm. > **Warning** > > Rotating revokes the current key immediately and issues a new one. Anything still sending the old key stops working until you give it the new one. The new key appears once, the same way a new key does. Save it before closing the dialog. ## Revoke a key Open the key's row actions and click **Revoke Key**. Type the key's name to confirm, then click **Revoke API Key**. From then on, every request that sends the key returns `401 Unauthorized`. A revoked key stays in the list under **Revoked**, and it cannot be turned back on. ## Connected Applications If your organization uses the [MCP server](/mcp), the same page has a **Connected Applications** tab. It lists the assistants people have connected, such as Claude or Cursor, and lets you close a connection. [Approving a connection](/mcp/approving-a-connection) covers that tab. ## Next * [Authentication](/authentication) is the API side: key prefixes, access levels and where to put a key * [CLI authentication](/cli/authentication) is how `l4` finds a key or signs you in instead * [Members and roles](/account/members-and-roles) decides who can manage keys --- # API Quickstart Source: https://docs.levelfour.ai/api-quickstart.md LevelFour publishes official SDKs for Python, TypeScript and Go, all generated from the same OpenAPI spec. Install one, give it an API key, and your code reads the same cloud spend and savings backlog the dashboard shows. ## Prerequisites * A LevelFour account with API access enabled. * An API key, created in the dashboard under **Settings > API Keys**. [Authentication](/authentication) has the key formats and what each scope reaches. ## Set up ### Install an SDK **Python** ```bash pip install levelfour ``` **TypeScript** ```bash npm install levelfour ``` **Go** ```bash go get github.com/LevelFourAI/levelfour-go ``` ### Set your API key Export the key as an environment variable. All SDKs auto-detect it: ```bash export LEVELFOUR_API_KEY="l4_live_your_key_here" ``` > **Tip** > > Keep the key out of anything you commit. An `l4_live_` key reaches production data, and an `l4_test_` key is the one to experiment with. [Authentication](/authentication#security-best-practices) covers keeping a key out of your source. ### Make your first request Call the `whoami` endpoint: **Python** ```python from levelfour import LevelFour client = LevelFour() me = client.auth.get_whoami() print(me) ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); const me = await client.auth.getWhoami(); console.log(me); ``` **Go** ```go package main import ( "context" "fmt" "log" "github.com/LevelFourAI/levelfour-go/levelfour" ) func main() { client, err := levelfour.NewClient("") if err != nil { log.Fatal(err) } me, err := client.Auth.GetWhoami(context.Background()) if err != nil { log.Fatal(err) } fmt.Println(me) } ``` > **Success** > > That response is the proof: the SDK found your key and the API accepted it. > **Error** > > A `401` back from that call means the API rejected the key. Re-check the export, then confirm the key is still active under **Settings > API Keys**. [Error handling](/error-handling) maps each status code to a typed error class. ## Read your data Neither of these calls depends on the other, so run whichever you need. ### Savings by provider One call totals the potential savings LevelFour has found, split by cloud provider: **Python** ```python summary = client.recommendations.get_savings_by_provider() print(summary) ``` **TypeScript** ```typescript const summary = await client.recommendations.getSavingsByProvider(); console.log(summary); ``` **Go** ```go summary, err := client.Recommendations.GetSavingsByProvider(context.Background()) if err != nil { log.Fatal(err) } fmt.Println(summary) ``` ### List recommendations The list method pages itself, so one loop walks every recommendation: **Python** ```python page = client.recommendations.list(page_size=10) for rec in page: print(f"{rec.service}: ${rec.monthly_savings}/mo") ``` **TypeScript** ```typescript for await (const rec of await client.recommendations.list({ page_size: 10 })) { console.log(`${rec.service}: $${rec.monthly_savings}/mo`); } ``` **Go** ```go page, err := client.Recommendations.List( context.Background(), &levelfour.ListRecommendationsRequest{PageSize: levelfour.Int(10)}, ) if err != nil { log.Fatal(err) } iter := page.Iterator() for iter.Next(context.Background()) { rec := iter.Current() fmt.Printf("%s: $%.2f/mo\n", rec.Service, rec.MonthlySavings) } if err := iter.Err(); err != nil { log.Fatal(err) } ``` Every method, parameter and response shape lives in [Recommendations](/resources/recommendations). [Pagination](/pagination) covers the response envelope and taking page control by hand. ## Next * [Authentication](/authentication) covers key scopes, rotation, and what to do when a key leaks * [SDKs](/sdks) is the per-language reference, with constructor options and sub-clients * [Resources](/resources) is every method, grouped by the resource it reads * [Error handling](/error-handling) maps each status code to a typed error class, and names what the SDKs retry before you see it * [Pagination](/pagination) is the response envelope and manual page control when the auto-iterator is not what you want * [CLI](/cli) is the same data from the terminal, where `l4 whoami` stands in for this page * [MCP](/mcp) puts the same data in Claude Code, Claude, Cursor, VS Code or Windsurf * [Quickstart](/quickstart) connects the cloud accounts these numbers come from --- # Authentication Source: https://docs.levelfour.ai/authentication.md Every request to the LevelFour API carries an API key. The prefix on the key says which environment it reaches, and the scope on it says what it may do there. ## API keys Create keys in the LevelFour dashboard under **Settings > API Keys**, and pass one as a Bearer token. ### Key formats | Prefix | Environment | Use Case | | ---------- | ----------- | ------------------------------------- | | `l4_live_` | Production | Live data, real optimizations | | `l4_test_` | Test | Safe for development, no side effects | ## Key scopes Each key carries a scope, set when you create it. The default is `read`. The API and SDKs take the values `read` and `read-write`, and the dashboard shows the same two as **Read-only** and **Read & write**. | Scope (API value) | Dashboard label | Allowed | | ----------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read` | Read-only | All read endpoints: list and fetch costs, recommendations, savings, providers, commitments, and anomalies. | | `read-write` | Read & write | Everything a `read` key can do, plus the endpoints that change state in LevelFour: accepting or rejecting a recommendation, adding rejection feedback, curating the resources a saving applies to, choosing an implementation method, and requesting execution. | > **Error** > > A `read` key that calls a write endpoint receives `403 Forbidden` with the detail `API key scope insufficient`. No key of either scope changes anything in your cloud account. Requesting execution starts the rollout in LevelFour and moves the recommendation's implementation to in progress. The step that reaches into your account is the savings grant. A `read-write` key can create one, but the grant only returns a link to scoped, time-limited access, and nothing changes until a person with access to your cloud account launches it. Create read-only keys for monitoring, dashboards, and CI checks, and reserve `read-write` keys for automation that records decisions. The [MCP server](/mcp) takes these keys too, alongside its own sign-in flow. Its writes carry a saving from the decision through to the rollout queue, plus virtual tags, cost alerts and anomaly status, and [Tools](/mcp/tools) lists every one. [Connecting a client](/mcp/authentication) compares the two credentials and says which scope each tool needs. ## Configuration ### Environment variable (recommended) All SDKs auto-detect the `LEVELFOUR_API_KEY` environment variable: ```bash export LEVELFOUR_API_KEY="l4_live_your_key_here" ``` **Python** ```python from levelfour import LevelFour client = LevelFour() ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); ``` **Go** ```go client, err := levelfour.NewClient("") ``` ### Explicit API key Pass the key directly to the constructor: **Python** ```python client = LevelFour(api_key="l4_live_your_key_here") ``` **TypeScript** ```typescript const client = new LevelFourClient({ apiKey: "l4_live_your_key_here" }); ``` **Go** ```go client, err := levelfour.NewClient("l4_live_your_key_here") ``` ## Verifying your key Call the `whoami` endpoint: **Python** ```python me = client.auth.get_whoami() print(me) ``` **TypeScript** ```typescript const me = await client.auth.getWhoami(); console.log(me); ``` **Go** ```go me, err := client.Auth.GetWhoami(context.Background()) ``` > **Success** > > An answer from `whoami` is the proof the key is live. A data endpoint is weaker evidence, because it can fail for reasons that have nothing to do with the credential. ## Managing API keys > **Note** > > Creating, listing, rotating, and revoking keys is available only to signed-in dashboard users, never to API keys, regardless of scope. [API Keys](/account/api-keys) has the SDK method for each of those, its parameters, and the optional expiration date. ### Key rotation Rotation creates a new secret for the same key ID. The old secret is immediately invalidated. > **Warning** > > Store the new key immediately after rotation. It will not be shown again. ## Security best practices > **Warning** > > Never commit an API key to version control. Keep it in an environment variable or a secrets manager, and rotate it the moment you suspect it has been compromised. Reach for an `l4_test_` key in development and in CI/CD pipelines. Set an expiration date on any key you mint for a temporary integration. ## Next * [API Keys](/account/api-keys) is every method for creating, listing, rotating and revoking a key * [CLI authentication](/cli/authentication) is how `l4` resolves a credential, and the read-write key its write commands need * [Connecting a client](/mcp/authentication) covers the sign-in flow an assistant can use instead of a key * [Enterprise SSO (SAML)](/account/sso) is signing in to the dashboard with your own identity provider --- # SDK Overview Source: https://docs.levelfour.ai/sdks.md Python, TypeScript and Go, each generated from the same OpenAPI specification with [Fern](https://buildwithfern.com). Method and option names follow the conventions of the language you are working in. The API underneath is the same one. | SDK | Package | Min Version | | ------------------------------ | ------------------------------------- | ------------ | | [Python](/sdks/python) | `levelfour` | Python 3.11+ | | [TypeScript](/sdks/typescript) | `levelfour` | Node.js 22+ | | [Go](/sdks/go) | `github.com/LevelFourAI/levelfour-go` | Go 1.24+ | > **Tip** > > Working in another language? The SDKs sit on the same REST API. [API reference](/api-reference) has the endpoints, and [`l4 api`](/cli/commands/api) sends an authenticated request to any of them from a shell. The behavior all three share lives on the page that owns it: * The API key goes in the constructor, and falls back to the `LEVELFOUR_API_KEY` environment variable when you leave it out. [Authentication](/authentication) has key formats, scopes and rotation. * A typed error hierarchy maps HTTP status codes to specific exception classes. [Error handling](/error-handling) has the status table, and the failures each client retries before you ever see them. * Auto-pagination iterators walk large result sets. [Pagination](/pagination) has the response envelope and the iteration forms. * Webhook signature verification using HMAC-SHA256 (Standard Webhooks). [Webhooks](/webhooks) has the header contract, the algorithm and the timestamp tolerance. ## Constructor options The same settings in every SDK, under each language's own name. | Option | Python | TypeScript | Go | Default | | ----------- | ------------- | ------------------ | ---------------------------- | --------------------------- | | API Key | `api_key` | `apiKey` | 1st positional arg | `LEVELFOUR_API_KEY` env var | | Base URL | `base_url` | `baseURL` | `levelfour.WithBaseURL()` | `https://api.levelfour.ai` | | Timeout | `timeout` | `timeoutInSeconds` | `levelfour.WithHTTPClient()` | 30s | | Max Retries | `max_retries` | `maxRetries` | `levelfour.WithMaxRetries()` | 2 | | HTTP Client | `http_client` | `fetch` | `levelfour.WithHTTPClient()` | Built-in | > **Note** > > Go has no standalone timeout option, which is why two rows name the same function. Set `Timeout` on the `http.Client` you hand to `levelfour.WithHTTPClient()`. The [Go SDK](/sdks/go) page has the example. ## Sub-clients Each client hangs the resources off a sub-client. The names follow the language. | Sub-Client | Python | TypeScript | Go | | ------------------------ | ------------------------------ | ------------------------------ | ------------------------------ | | Recommendations | `client.recommendations` | `client.recommendations` | `client.Recommendations` | | Recommendations Audit | `client.recommendations.audit` | `client.recommendations.audit` | `client.Recommendations.Audit` | | Costs | `client.costs` | `client.costs` | `client.Costs` | | Providers | `client.providers` | `client.providers` | `client.Providers` | | Webhooks | `client.webhooks` | `client.webhooks` | `client.Webhooks` | | Auth | `client.auth` | `client.auth` | `client.Auth` | | Cost alerts | `client.cost_alerts` (0.2.0) | `client.costAlerts` (0.2.0) | `client.CostAlerts` | | Realized savings detail | - | - | `client.Audit` | | Commitments | - | - | `client.Commitments` | | Google Cloud | - | - | `client.GoogleCloud` | | Tags | - | - | `client.Tags` | | Cost views | - | - | `client.CostViews` | | Anomalies | - | - | `client.Anomalies` | | Accounts | - | - | `client.Accounts` | | Integrations | - | - | `client.Integrations` | | Automated Savings grants | - | - | `client.SavingsGrants` | | Repository binding | - | - | `client.RepoBinding` | A row with only a Go name is in the Go SDK today. Every one of them is also plain REST, so any language reaches it through the [API reference](/api-reference). API keys are created and rotated in the dashboard, not through an SDK. [API keys](/account/api-keys) has the steps. ## Next * [Python](/sdks/python), [TypeScript](/sdks/typescript) and [Go](/sdks/go) each carry the install command, the full constructor and the per-request options * [API Quickstart](/api-quickstart) is install, key, first call * [Resources](/resources) is every method with its parameters and response shapes, in Python, TypeScript and Go * [API reference](/api-reference) is the REST surface the clients are generated from * [CLI](/cli) is the same data from the terminal --- # Python SDK Source: https://docs.levelfour.ai/sdks/python.md `levelfour` is the Python client for the LevelFour API, in a sync form and an async one. Everything here is about the client itself. What each method takes and returns lives on the reference page for its sub-client, listed below. ## Installation ```bash pip install levelfour ``` ## Client setup ### Sync client ```python from levelfour import LevelFour client = LevelFour( api_key="l4_live_...", base_url="https://api.levelfour.ai", timeout=30.0, max_retries=2, ) ``` With `LEVELFOUR_API_KEY` in the environment, the constructor takes nothing: ```python client = LevelFour() ``` > **Tip** > > Prefer the empty constructor and the environment variable. An `api_key` written as an argument reaches version control with the file, and it stays in the history after you delete the line. [Authentication](/authentication#security-best-practices) covers keeping a key out of your source. `LevelFour` works as a context manager: ```python with LevelFour() as client: summary = client.recommendations.get_savings_by_provider() ``` ### Async client ```python import asyncio from levelfour import AsyncLevelFour async def main(): async with AsyncLevelFour() as client: summary = await client.recommendations.get_savings_by_provider() asyncio.run(main()) ``` ### Constructor options | Parameter | Type | Default | | ------------- | ------------------------ | --------------------------- | | `api_key` | `str \| None` | `LEVELFOUR_API_KEY` env var | | `base_url` | `str` | `https://api.levelfour.ai` | | `timeout` | `float` | `30.0` | | `max_retries` | `int` | `2` | | `http_client` | `httpx.Client \| None` | Built-in | | `headers` | `dict[str, str] \| None` | `None` | > **Note** > > One type changes with the client you built. `http_client` takes an `httpx.Client` on `LevelFour` and an `httpx.AsyncClient` on `AsyncLevelFour`. ### Cloning a client `with_options` returns a copy of the client with the options you name changed: ```python long_timeout_client = client.with_options(timeout=60.0) ``` ## What the client exposes Every sub-client hangs off the client you just built. The reference pages carry each method with its parameters, its response shape and a worked example. | Attribute | Reference | | ------------------------------ | --------------------------------------------- | | `client.recommendations` | [Recommendations](/resources/recommendations) | | `client.recommendations.audit` | [Savings](/resources/savings) | | `client.costs` | [Costs](/resources/costs) | | `client.providers` | [Providers](/resources/providers) | | `client.webhooks` | [Webhooks](/webhooks) | | `client.auth` | [Authentication](/authentication) | | `client.cost_alerts` (0.2.0) | [Cost Alerts](/resources/cost-alerts) | ## Pagination Methods that return paginated results return a `SyncPager`, or an `AsyncPager` on the async client. [Pagination](/pagination) has the iteration forms, sync and async, alongside the response envelope and the page size limit. ## Request options Override the client's defaults for one call: ```python from levelfour.core.request_options import RequestOptions opts: RequestOptions = { "timeout_in_seconds": 60, "max_retries": 5, "additional_headers": {"X-Request-Id": "abc123"}, } summary = client.recommendations.get_savings_by_provider(request_options=opts) ``` | Option | Type | Description | | ----------------------------- | ---------------- | ------------------------------ | | `timeout_in_seconds` | `int` | Override request timeout | | `max_retries` | `int` | Override max retry attempts | | `additional_headers` | `dict[str, Any]` | Extra headers for this request | | `additional_query_parameters` | `dict[str, Any]` | Extra query parameters | | `additional_body_parameters` | `dict[str, Any]` | Extra body parameters | ## Next * [Resources](/resources) has every method with its parameters and response shape, in Python alongside TypeScript and Go * [Error handling](/error-handling) is the exception hierarchy, what each error carries, and which failures the client retries * [Webhooks](/webhooks) is the verifier, the signature headers, the event types and the payloads * [Authentication](/authentication) covers `LEVELFOUR_API_KEY`, key scopes, rotation and revocation * [API Quickstart](/api-quickstart) is install, key, first call * [TypeScript SDK](/sdks/typescript) and [Go SDK](/sdks/go) cover the same surface in the other two languages --- # TypeScript SDK Source: https://docs.levelfour.ai/sdks/typescript.md `levelfour` is the LevelFour client for TypeScript. This page covers installing it, constructing it, and the behavior that belongs to the client rather than to the API. Every method, with its parameters and response shapes, is on the page that owns its sub-client, listed below. ## Installation ```bash npm install levelfour ``` ## Client setup ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient({ apiKey: "l4_live_...", baseURL: "https://api.levelfour.ai", timeoutInSeconds: 30, maxRetries: 2, }); ``` > **Tip** > > The `apiKey` literal above is for illustration. A key committed to source stays in the history after you delete the line, so read it from the environment or a secrets manager instead. [Authentication](/authentication#security-best-practices) covers the rest. Called with no arguments, the client reads `LEVELFOUR_API_KEY` from the environment. ```typescript const client = new LevelFourClient(); ``` ### Constructor options | Parameter | Type | Default | | ------------------ | ------------------------ | --------------------------- | | `apiKey` | `string` | `LEVELFOUR_API_KEY` env var | | `baseURL` | `string` | `https://api.levelfour.ai` | | `timeoutInSeconds` | `number` | `30` | | `maxRetries` | `number` | `2` | | `fetch` | `typeof fetch` | Built-in | | `defaultHeaders` | `Record` | `undefined` | ## What the client exposes Each resource hangs off the client as a sub-client. Its owning page carries the methods, their parameters and their response shapes. | Attribute | Reference | | ------------------------------ | --------------------------------------------- | | `client.recommendations` | [Recommendations](/resources/recommendations) | | `client.recommendations.audit` | [Savings](/resources/savings) | | `client.costs` | [Costs](/resources/costs) | | `client.providers` | [Providers](/resources/providers) | | `client.webhooks` | [Webhooks](/webhooks) | | `client.auth` | [Authentication](/authentication) | | `client.costAlerts` (0.2.0) | [Cost Alerts](/resources/cost-alerts) | ## Pagination Methods that return paginated results resolve to a `Page` object that implements `AsyncIterable`. ```typescript for await (const rec of await client.recommendations.list({ page_size: 50 })) { console.log(rec.recommendation_id); } ``` Await the call once to get the `Page`, then let `for await` pull each item from it. [Pagination](/pagination) has page-level iteration, manual navigation and `collectAll`. ## Errors ```typescript import { LevelFourClient, NotFoundError, LevelFourError } from "levelfour"; const client = new LevelFourClient(); try { await client.recommendations.get({ recommendation_id: "rec_nonexistent" }); } catch (err) { if (err instanceof NotFoundError) { console.log("Not found:", err.message); } else if (err instanceof LevelFourError) { console.log(`API error ${err.statusCode}: ${err.message}`); } } ``` Every error class extends `LevelFourError`, so the last branch catches whatever the earlier ones miss. [Error Handling](/error-handling) is the full hierarchy, the properties an error carries, and which failures the client retries. ## Webhook verification `WebhookVerifier` checks the signature on an incoming request and throws `WebhookVerificationError` when it does not match. ```typescript import { WebhookVerifier, WebhookVerificationError } from "levelfour"; const verifier = new WebhookVerifier("whsec_your_signing_secret"); try { const payload = verifier.verify(requestBody, { "webhook-id": headers["webhook-id"], "webhook-timestamp": headers["webhook-timestamp"], "webhook-signature": headers["webhook-signature"], }); console.log("Verified event:", payload.type); } catch (err) { if (err instanceof WebhookVerificationError) { console.log("Verification failed:", err.message); } } ``` The header contract, the signature algorithm, the timestamp tolerance and the event payloads are on [Webhooks](/webhooks). ## Raw responses Every method returns an `HttpResponsePromise`. Await it for the parsed data, or call `.withRawResponse()` to get the status and headers alongside it. ```typescript const data = await client.costs.getSummary(); const { data: costs, rawResponse } = await client.costs .getSummary() .withRawResponse(); console.log(rawResponse.status); console.log(rawResponse.headers); ``` ## Request options A request options object overrides the client defaults for one call. When a method takes parameters, the options object goes second. `getSavingsByProvider` takes none, so here it is the only argument. ```typescript const controller = new AbortController(); const summary = await client.recommendations.getSavingsByProvider({ timeoutInSeconds: 60, maxRetries: 5, headers: { "X-Request-Id": "abc123" }, abortSignal: controller.signal, }); ``` | Option | Type | Description | | ------------------ | ------------------------ | ------------------------------ | | `timeoutInSeconds` | `number` | Override request timeout | | `maxRetries` | `number` | Override max retry attempts | | `headers` | `Record` | Extra headers for this request | | `abortSignal` | `AbortSignal` | Cancel the request | ## Next * [Resources](/resources) has every method with its parameters and response shapes, in TypeScript alongside Python and Go * [Pagination](/pagination) walks through page-level iteration, manual navigation and `collectAll` * [Error Handling](/error-handling) is the full error hierarchy and which failures the client retries * [Webhooks](/webhooks) is the event types, the payloads and the signature contract * [Authentication](/authentication) covers key formats, scopes, rotation and the `whoami` check * [SDK Overview](/sdks) compares the three clients option by option --- # Go SDK Source: https://docs.levelfour.ai/sdks/go.md The Go client groups the LevelFour API into one namespace per resource on the client struct: `client.Costs`, `client.Recommendations`, `client.Commitments` and the rest. Every method takes a `context.Context` first. > **Note** > > Per-provider methods take a `providerID` string after the context (`"aws"`, `"gcp"`, `"azure"`, `"k8s"`). Methods with optional filters take a request struct next. Pass an empty one, such as `&levelfour.GetSummaryCostsRequest{}`, to take every default. ## Installation ```bash go get github.com/LevelFourAI/levelfour-go@v0.2.0 ``` ## Client setup ```go import "github.com/LevelFourAI/levelfour-go/levelfour" client, err := levelfour.NewClient("l4_live_...") if err != nil { log.Fatal(err) } ``` Pass an empty string and the client reads the key from the environment instead. [Authentication](/authentication#environment-variable-recommended) names the variable. ```go client, err := levelfour.NewClient("") ``` Confirm the client is wired before larger calls. `GetWhoami` answers only when the base URL resolves and the key is valid, which makes it a good CI smoke test. ```go me, err := client.Auth.GetWhoami(ctx) ``` ### Constructor options ```go client, err := levelfour.NewClient("l4_live_...", levelfour.WithBaseURL("https://api.staging.levelfour.ai"), levelfour.WithMaxRetries(3), levelfour.WithHTTPClient(&http.Client{Timeout: 60 * time.Second}), ) ``` | Option | Function | Default | | ----------- | ----------------------------- | -------------------------- | | Base URL | `levelfour.WithBaseURL(url)` | `https://api.levelfour.ai` | | Max Retries | `levelfour.WithMaxRetries(n)` | `2` | | No Retries | `levelfour.WithNoRetries()` | Retries enabled | | HTTP Client | `levelfour.WithHTTPClient(c)` | 30s timeout | ## Recommendations ```go savings, err := client.Recommendations.GetSavingsByProvider(ctx) potential, err := client.Recommendations.GetPotentialSavings(ctx) overview, err := client.Recommendations.GetOverview(ctx) processing, err := client.Recommendations.ListInProgress(ctx) detail, err := client.Recommendations.Get(ctx, "REC-1234") page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListRecommendationsRequestSortOrder("desc").Ptr(), }) top, err := client.Recommendations.GetTop(ctx, "aws") recs, err := client.Recommendations.ListByProvider(ctx, "aws", &levelfour.ListByProviderRecommendationsRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListByProviderRecommendationsRequestSortOrder("desc").Ptr(), Service: []string{"EC2"}, DisplayStatus: []string{"available", "pending"}, }, ) providerOverview, err := client.Recommendations.GetProviderOverview(ctx, "aws") providerFilters, err := client.Recommendations.GetProviderFilters(ctx, "aws") providerPotential, err := client.Recommendations.GetProviderPotentialSavingsSummary(ctx, "aws") providerPotentialPage, err := client.Recommendations.ListProviderPotentialSavings(ctx, "aws", &levelfour.ListProviderPotentialSavingsRecommendationsRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), }, ) ``` Acting on one recommendation: narrow the resources it covers, record why it was rejected, read its activity, and ask an admin to release it. ```go _, err := client.Recommendations.UpdateResourceSelection(ctx, "REC-1234", &levelfour.ResourceSelectionRequest{ SelectedResources: []string{"vol-0abc1234def567890"}, }, ) _, err = client.Recommendations.AddRejectionFeedback(ctx, "REC-1234", &levelfour.RejectionFeedbackRequest{ Reason: "not_applicable", Explanation: levelfour.String("This volume backs a disaster recovery test."), }, ) activity, err := client.Recommendations.GetRecommendationActivity(ctx, "REC-1234") approval, err := client.Recommendations.RequestExecution(ctx, "REC-1234", &levelfour.RequestExecutionBody{ ImplementationMethod: levelfour.String("one-click"), }, ) waiting, err := client.Recommendations.ListPendingApprovals(ctx) ``` [Recommendations](/resources/recommendations#acting-on-a-recommendation) has what each of these accepts and returns. ## Recommendations audit Realized savings (audited completions). Account-wide and per-provider variants both live on `client.Recommendations.Audit`. ```go summary, err := client.Recommendations.Audit.GetSummary(ctx) page, err := client.Recommendations.Audit.List(ctx, &levelfour.ListAuditRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListAuditRequestSortOrder("desc").Ptr(), Start: levelfour.String("2025-01-01"), End: levelfour.String("2025-03-31"), }) providerSummary, err := client.Recommendations.Audit.GetProviderSummary(ctx, "aws") providerPage, err := client.Recommendations.Audit.ListByProvider(ctx, "aws", &levelfour.ListByProviderAuditRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), }, ) ``` ## Realized savings detail `client.Audit` returns realized-savings detail for one audited row. The id is the numeric `audit_id` a row in the audit list carries. The list and summary views live on `client.Recommendations.Audit` above. ```go detail, err := client.Audit.GetRealizedAuditDetail(ctx, 4821) ``` ## Costs ```go summary, err := client.Costs.GetSummary(ctx, &levelfour.GetSummaryCostsRequest{}) breakdown, err := client.Costs.List(ctx, &levelfour.ListCostsRequest{ Format: levelfour.String("table"), Period: levelfour.String("2025-03"), Page: levelfour.Int(1), PageSize: levelfour.Int(50), SortBy: levelfour.String("cost"), SortOrder: levelfour.ListCostsRequestSortOrder("desc").Ptr(), }) daily, err := client.Costs.GetDailyCosts(ctx, &levelfour.GetDailyCostsCostsRequest{ Start: levelfour.String("2025-03-01T00:00:00.000Z"), End: levelfour.String("2025-03-31T00:00:00.000Z"), }) monthly, err := client.Costs.GetMonthlyCosts(ctx) forecast, err := client.Costs.GetForecast(ctx, &levelfour.GetForecastAPIV1CostsForecastGetRequest{}) growing, err := client.Costs.GetTopGrowing(ctx, &levelfour.GetTopGrowingAPIV1CostsTopGrowingGetRequest{ Limit: levelfour.Int(5), }) providerSummary, err := client.Costs.GetProviderSummary(ctx, "aws", &levelfour.GetProviderSummaryCostsRequest{}) providerFilters, err := client.Costs.GetProviderFilters(ctx, "aws", &levelfour.GetProviderFiltersCostsRequest{}, ) providerList, err := client.Costs.ListByProvider(ctx, "aws", &levelfour.ListByProviderCostsRequest{ Format: levelfour.String("table"), Page: levelfour.Int(1), PageSize: levelfour.Int(50), }, ) timeline, err := client.Costs.GetProviderTimeline(ctx, "aws", &levelfour.GetProviderTimelineCostsRequest{ Start: levelfour.String("2025-01-01T00:00:00.000Z"), End: levelfour.String("2025-03-31T00:00:00.000Z"), }, ) ``` [Costs](/resources/costs) also covers usage and unit costs, cost by tag, allocation coverage and Google Cloud label grouping. ## Providers ```go providers, err := client.Providers.List(ctx) topSavers, err := client.Providers.GetProviderTopSavers(ctx, "aws") invoices, err := client.Providers.GetProviderInvoices(ctx, "gcp", &levelfour.GetProviderInvoicesAPIV1ProvidersProviderIDCostsInvoicesGetRequest{}, ) ``` `client.Providers` lists connected providers and a few provider-only reads. Most per-provider drill-downs live on the namespace that owns the resource. | Per-provider data | Namespace | Methods | | ----------------------- | ------------------------------ | ----------------------------------------------------------------------------------- | | Cost | `client.Costs` | `GetProviderSummary`, `GetProviderFilters`, `ListByProvider`, `GetProviderTimeline` | | Recommendations | `client.Recommendations` | `ListByProvider`, `GetProviderOverview`, `GetTop` | | Audit | `client.Recommendations.Audit` | `ListByProvider`, `GetProviderSummary` | | Invoices and top savers | `client.Providers` | `GetProviderInvoices`, `GetProviderTopSavers` | ## Commitments Reserved Instances and Savings Plans. [Commitments](/resources/commitments) has what each read returns. ```go overview, err := client.Commitments.GetOverview(ctx, &levelfour.GetOverviewAPIV1CommitmentsOverviewGetRequest{}) reservations, err := client.Commitments.GetInventory(ctx, &levelfour.GetInventoryAPIV1CommitmentsInventoryGetRequest{ Instrument: "ri", }) utilization, err := client.Commitments.GetUtilization(ctx, &levelfour.GetUtilizationAPIV1CommitmentsUtilizationGetRequest{ Type: levelfour.String("sp"), Granularity: levelfour.String("monthly"), }) coverage, err := client.Commitments.GetCoverageRates(ctx, &levelfour.GetCoverageRatesAPIV1CommitmentsCoverageRatesGetRequest{}) esr, err := client.Commitments.GetEsr(ctx, &levelfour.GetEsrAPIV1CommitmentsEsrGetRequest{}) plan, err := client.Commitments.GetRenewalPlan(ctx, "arn:aws:savingsplans::123456789012:savingsplan/abcd1234") ``` ## Google Cloud Reads that only Google Cloud answers. [Google Cloud](/resources/google-cloud) has the full set. ```go availability, err := client.GoogleCloud.GetDataAvailability(ctx) budgets, err := client.GoogleCloud.GetBudgets(ctx) utilization, err := client.GoogleCloud.GetUtilizationSummary(ctx) access, err := client.GoogleCloud.GetAccess(ctx) ``` ## Accounts Connected cloud accounts, the modules your organization has, and connected work tools. ```go accounts, err := client.Accounts.ListConnectedAccounts(ctx, &levelfour.ListConnectedAccountsAPIV1AccountsGetRequest{}, ) connections, err := client.Accounts.ListConnections(ctx) modules, err := client.Accounts.ListCustomerModules(ctx) installs, err := client.Accounts.ListGithubInstallations(ctx) ``` [Accounts](/resources/accounts) covers the rest. ## Anomalies ```go summary, err := client.Anomalies.GetSummary(ctx, &levelfour.GetSummaryAPIV1AnomaliesSummaryGetRequest{}) anomalies, err := client.Anomalies.ListAnomalies(ctx, &levelfour.ListAnomaliesAPIV1AnomaliesGetRequest{ Provider: levelfour.String("aws"), PageSize: levelfour.Int(20), }) anomaly, err := client.Anomalies.GetAnomaly(ctx, "anomaly_id") ``` ## Cost alerts [Cost Alerts](/resources/cost-alerts) has every method, with the rule types and what each field takes. ```go alerts, err := client.CostAlerts.ListAlerts(ctx) enabled := false // pauses it; true resumes it _, err = client.CostAlerts.UpdateAlert(ctx, alertID, &levelfour.UpdateAlertRequest{Enabled: &enabled}) _, err = client.CostAlerts.DeleteAlert(ctx, alertID) ``` ## Cost views A cost view is a saved set of filters and groupings you can read back and reuse. ```go views, err := client.CostViews.ListViews(ctx) view, err := client.CostViews.CreateView(ctx, &levelfour.CostViewBody{ Name: "Production compute", Providers: []string{"aws"}, Filters: map[string][]string{"service": {"Amazon Elastic Compute Cloud - Compute"}}, GroupBy: []string{"account_id"}, }) ``` ## Automated Savings grants A grant is scoped, time-limited access that lets LevelFour apply a saving for you. Create one for a recommendation, or for a batch, then poll it until it reports connected. ```go grant, err := client.SavingsGrants.CreateGrant(ctx, &levelfour.CreateGrantRequest{ BatchParentID: "REC-1234", }) status, err := client.SavingsGrants.GetGrantStatus(ctx, "grant_id") ``` [Automated Savings](/connectors/aws/automated-savings) covers the access the grant stands on. ## Repository binding For a saving delivered as an infrastructure-as-code pull request, choose which repositories it opens against. ```go installs, err := client.RepoBinding.ListInstallations(ctx) targets, err := client.RepoBinding.BindRepos(ctx, "REC-1234", &levelfour.BindReposRequest{ InstallationID: 12345678, Repos: []string{"acme/infrastructure"}, }) bound, err := client.RepoBinding.ListTargets(ctx, "REC-1234") ``` ## Integrations Read a connected work tool and file a task in it. `tool` is the tool's name, such as `jira`. ```go connection, err := client.Integrations.GetToolConnection(ctx, "jira") task, err := client.Integrations.CreateToolTask(ctx, "jira", &levelfour.CreateTaskPayload{ RecommendationID: "REC-1234", Title: "Delete the unattached volume in us-east-1", }) ``` ## Webhooks ```go endpoints, err := client.Webhooks.List(ctx) endpoint, err := client.Webhooks.Register(ctx, &levelfour.RegisterEndpointRequest{ URL: "https://example.com/webhook", EventTypes: []string{"recommendation.accepted", "optimization.completed"}, }) _, err = client.Webhooks.Delete(ctx, "ep_123") ``` ## Auth ```go me, err := client.Auth.GetWhoami(ctx) ``` ## Pagination Paginated methods return a `core.Page` with typed items and an iterator that fetches the next page for you. ```go page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ PageSize: levelfour.Int(50), }) if err != nil { log.Fatal(err) } iter := page.Iterator() for iter.Next(ctx) { rec := iter.Current() fmt.Printf("%s: $%.2f/mo\n", rec.Service, rec.MonthlySavings) } if err := iter.Err(); err != nil { log.Fatal(err) } ``` [Pagination](/pagination) has the collect-all and manual-navigation forms, and the page size defaults. ## Error handling Every error is a typed struct. Match one with `errors.As` and add a case per type you handle. ```go import "errors" detail, err := client.Recommendations.Get(ctx, "rec_nonexistent") if err != nil { var notFoundErr *levelfour.NotFoundError switch { case errors.As(err, ¬FoundErr): fmt.Printf("Not found: %v\n", notFoundErr.Body) default: fmt.Printf("Error: %v\n", err) } } ``` [Error Handling](/error-handling) has the remaining types, and the failures the client retries before you ever see them. ## Webhook verification Inside your HTTP handler, where `r` is the request and `body` its bytes: ```go import "github.com/LevelFourAI/levelfour-go/levelfour/webhooks" verifier, err := webhooks.NewVerifier("whsec_your_signing_secret") if err != nil { log.Fatal(err) } payload, err := verifier.Verify(r.Header, body) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } fmt.Printf("Verified event: %v\n", payload["type"]) ``` ### Full HTTP handler ```go title="main.go" package main import ( "fmt" "io" "log" "net/http" "github.com/LevelFourAI/levelfour-go/levelfour/webhooks" ) func main() { verifier, err := webhooks.NewVerifier("whsec_your_signing_secret") if err != nil { log.Fatal(err) } http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } payload, err := verifier.Verify(r.Header, body) if err != nil { http.Error(w, "invalid signature", http.StatusUnauthorized) return } fmt.Printf("Received event: %v\n", payload) w.WriteHeader(http.StatusOK) }) log.Println("Listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } ``` See [Webhooks](/webhooks) for event types and payloads, and [Timestamp tolerance](/webhooks#timestamp-tolerance) for the replay window and how to widen it. ## Request options The `option` package overrides client defaults for one request. ```go import "github.com/LevelFourAI/levelfour-go/option" summary, err := client.Recommendations.GetSavingsByProvider(ctx, option.WithMaxAttempts(5), option.WithHTTPHeader(http.Header{ "X-Request-Id": []string{"abc123"}, }), ) ``` | Option | Function | Description | | --------------- | ------------------------------- | ----------------------- | | Base URL | `option.WithBaseURL(url)` | Override base URL | | HTTP Client | `option.WithHTTPClient(c)` | Custom HTTP client | | Headers | `option.WithHTTPHeader(h)` | Extra headers | | Max Attempts | `option.WithMaxAttempts(n)` | Override retry attempts | | Auth Token | `option.WithToken(t)` | Override Bearer token | | Query Params | `option.WithQueryParameters(v)` | Extra query parameters | | Body Properties | `option.WithBodyProperties(m)` | Extra body properties | ## Next * [Resources](/resources) has the parameters and response shapes behind these methods * [Pagination](/pagination) has the page size defaults and iteration in the other two languages * [Error Handling](/error-handling) has the full status-code table and what each error carries * [Webhooks](/webhooks) has the event types, the payloads and the signature contract * [Authentication](/authentication) covers key formats, scopes, rotation and the `whoami` check * [SDK Overview](/sdks) compares the three clients option by option --- # CLI Overview Source: https://docs.levelfour.ai/cli.md `l4` runs LevelFour from the terminal. The official client reaches the same costs, recommendations and integrations as the dashboard, and adds what only a terminal can do: Terraform and CloudFormation cost estimates before you deploy, structured output for scripting, and an interactive TUI for browsing recommendations. ## Quick start ### Install ```bash brew install LevelFourAI/tap/levelfour ``` See [Installation](/cli/installation) for `go install`, prebuilt archives including Windows, and shell completion. ### Sign in ```bash l4 auth login ``` This opens your browser and signs you in. See [Authentication](/cli/authentication) for CI, where there is no browser to open. ### Confirm identity ```bash l4 whoami ``` > **Success** > > Your identity and organization printing back is the proof that a credential resolved and is bound to an organization. Exit code `4` instead means no credential resolved. ### Run something ```bash l4 costs summary l4 recommendations list --status available l4 estimate ./infra/ ``` `l4 costs summary` is the KPI overview, `l4 recommendations list --status available` shows pending savings opportunities, and `l4 estimate ./infra/` estimates the cost of the Terraform and CloudFormation under `./infra/`. [Recipes](/cli/recipes) has longer pipelines. ## Two binaries, same tool A single `brew install LevelFourAI/tap/levelfour` ships two interchangeable binaries with identical behavior, both from the one cask: * `l4`: short form, recommended for everyday use * `levelfour`: long form, useful when `l4` collides with another tool on your `PATH` Throughout the docs we use `l4`. Substitute `levelfour` if you prefer. ## Command groups | Group | Commands | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Core** | [`costs`](/cli/commands/costs), [`recommendations`](/cli/commands/recommendations), [`integrations`](/cli/commands/integrations), [`status`](/cli/commands/status), [`estimate`](/cli/commands/estimate), [`diff`](/cli/commands/diff), [`export`](/cli/commands/export), [`api`](/cli/commands/api), [`whoami`](/cli/authentication#inspect-the-active-session) | | **Recommendation writes** | [`rec accept`](/cli/commands/recommendations/accept), [`rec reject`](/cli/commands/recommendations/reject), [`rec execute`](/cli/commands/recommendations/execute) | | **[Commitments](/cli/commands/commitments)** | [`commitments summary`](/cli/commands/commitments#summary), [`commitments list`](/cli/commands/commitments#list), [`commitments expiring`](/cli/commands/commitments/expiring), [`commitments view`](/cli/commands/commitments#view), [`commitments renewal`](/cli/commands/commitments/renewal), [`commitments utilization`](/cli/commands/commitments#utilization), [`commitments coverage`](/cli/commands/commitments/coverage), [`commitments plan`](/cli/commands/commitments#plan), [`commitments contracts`](/cli/commands/commitments#contracts) | | **Tags** | [`tags list`](/cli/commands/tags/list), [`tags show`](/cli/commands/tags/show), [`tags coverage`](/cli/commands/tags/coverage), [`tags resources`](/cli/commands/tags/resources), [`tags costs`](/cli/commands/tags/costs), [`tags preview`](/cli/commands/tags/preview), [`tags apply`](/cli/commands/tags/apply), [`tags delete`](/cli/commands/tags/delete) | | **[Cost alerts](/cli/commands/alerts)** | [`alerts list`](/cli/commands/alerts#list), [`alerts get`](/cli/commands/alerts#get), [`alerts sources`](/cli/commands/alerts#sources), [`alerts events`](/cli/commands/alerts#events), [`alerts create`](/cli/commands/alerts/create), [`alerts update`](/cli/commands/alerts/update), [`alerts pause`](/cli/commands/alerts#pause-and-resume), [`alerts resume`](/cli/commands/alerts#pause-and-resume), [`alerts delete`](/cli/commands/alerts/delete) | | **Agent integration** | [`mcp install`](/cli/commands/mcp/install), [`mcp uninstall`](/cli/commands/mcp/uninstall), [`mcp status`](/cli/commands/mcp/status), [`mcp serve`](/cli/commands/mcp/serve) | | **[Authentication](/cli/authentication)** | `auth login`, `auth logout`, `auth status`, `login` (alias) | | **Configuration** | [`config`](/cli/commands/config), [`telemetry`](/cli/commands/telemetry), [`completion`](/cli/installation#shell-completion) | Run `l4 --help` (or `--help` on any subcommand) for the full flag list. `rec` and `recs` are aliases for `recommendations`, and `costalerts` for `alerts`. ## Global flags | Flag | Effect | | ---------------------- | ---------------------------------------------------------------------- | | `--json` | Output as JSON for piping to `jq` or other tools | | `--jq ` | Filter the JSON output with a `jq` expression in-process | | `--template ` | Format output with a Go text template | | `--csv` | CSV output (export commands only) | | `--quiet` / `-q` | Suppress output; communicate via exit code | | `--no-color` | Disable ANSI colors. The `NO_COLOR` environment variable does the same | | `--web` / `-w` | Open the equivalent dashboard URL instead of printing | | `--token ` / `-t` | One-shot token override | | `--api ` | Point at a non-default API base URL | `--quiet` is mutually exclusive with `--json`, `--jq`, `--template`, and `--csv`. See [Output formats](/cli/output-formats) for what each one emits. ## Exit codes | Code | Meaning | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `0` | Success | | `1` | General error | | `2` | Issues found (`l4 estimate --fail-above` triggered, `l4 commitments expiring --fail-within` found a lapsing term, `l4 tags apply --dry-run` found a difference, or recommendations exceed a threshold in CI) | | `4` | Authentication required (no token, expired token, or invalid token) | | `130` | Interrupted (`SIGINT`, e.g. Ctrl+C) | These are stable. Script against them. ## Credentials `l4` looks for a credential in the `--token` flag, then the `LEVELFOUR_TOKEN` environment variable, then the OS keychain. Set `LEVELFOUR_TOKEN` from a secret in CI. On a laptop, run `l4 auth login`. [Authentication](/cli/authentication) has the full resolution order, the keychain each platform uses, and the troubleshooting table. ## Reporting issues File CLI bugs at [github.com/LevelFourAI/levelfour-cli/issues](https://github.com/LevelFourAI/levelfour-cli/issues). Include `l4 --version` output and the failing command. ## Next * [Installation](/cli/installation) has `go install`, prebuilt archives, CI images and shell completion * [Authentication](/cli/authentication) is the credential order in full, and the read-write key the write commands need * [Output formats](/cli/output-formats) is what `--json`, `--jq`, `--template` and `--csv` emit * [Commands](/cli/commands/costs) is the per-command reference * [Recipes](/cli/recipes) has shell pipelines validated against the live API * [MCP](/mcp) is the same data inside an assistant --- # Installation Source: https://docs.levelfour.ai/cli/installation.md Homebrew is the recommended route on macOS and Linux. `go install` needs a Go toolchain. Every [release on GitHub](https://github.com/LevelFourAI/levelfour-cli/releases) also ships static archives: * Linux: `amd64`, `arm64` * macOS: `amd64`, `arm64` * Windows: `amd64`, `arm64` ## Install | Method | Binaries installed | Needs | | ---------------- | -------------------- | --------------------------------------- | | Homebrew | `levelfour` and `l4` | macOS or Linux | | `go install` | `levelfour` only | a Go toolchain (1.26.3+) | | Prebuilt archive | `levelfour` and `l4` | no toolchain, just download and extract | **Homebrew** ```bash brew install LevelFourAI/tap/levelfour ``` The tap ships a single `levelfour` cask, and it installs both binaries: `levelfour` and `l4`. [Two binaries, same tool](/cli#two-binaries-same-tool) covers which name to use. **go install** With a Go toolchain (1.26.3+): ```bash go install github.com/LevelFourAI/levelfour-cli/cmd/levelfour@latest ``` The binary is named `levelfour`. Symlink to `l4` if you want the short form: ```bash ln -s "$(which levelfour)" "$(dirname "$(which levelfour)")/l4" ``` > **Note** > > `go install` builds only `levelfour`. The verify step below and the rest of these docs say `l4`, so make the symlink or substitute the name as you read. **Prebuilt archive** Archive naming: `levelfour-cli___.tar.gz` (`.zip` on Windows). Each archive contains both the `levelfour` and `l4` binaries. ```bash VERSION=0.1.0 curl -L https://github.com/LevelFourAI/levelfour-cli/releases/download/v${VERSION}/levelfour-cli_${VERSION}_linux_amd64.tar.gz \ | tar xz sudo mv levelfour l4 /usr/local/bin/ ``` Set `VERSION` to the release you want. The example pulls the Linux `amd64` archive. Checksums for every release are signed under `checksums.txt` on the Release page. ## Verify the installation ```bash l4 --version ``` ```text l4 version 0.1.0 (commit: 2e84aa4) ``` > **Success** > > A version line is the proof: the shell found the binary and it answered. Your version and commit will differ from the example. ## Shell completion Generate completion scripts for your shell: **bash** ```bash source <(l4 completion bash) # or persist: l4 completion bash | sudo tee /etc/bash_completion.d/l4 > /dev/null ``` **zsh** ```bash source <(l4 completion zsh) # or persist: l4 completion zsh > "${fpath[1]}/_l4" ``` **fish** ```bash l4 completion fish | source # or persist: l4 completion fish > ~/.config/fish/completions/l4.fish ``` **PowerShell** ```powershell l4 completion powershell | Out-String | Invoke-Expression ``` ## Updating `l4` checks for new versions in the background after each command and prints an update hint to stderr if a newer release is available. To upgrade: **Homebrew** ```bash brew upgrade levelfour # updates both binaries ``` **go install** ```bash go install github.com/LevelFourAI/levelfour-cli/cmd/levelfour@latest ``` ## CI and Docker images The CLI is a single static Go binary with no runtime dependencies. To pin a version inside a CI image: ```dockerfile title="Dockerfile" ARG LEVELFOUR_CLI_VERSION=0.1.0 RUN curl -fsSL https://github.com/LevelFourAI/levelfour-cli/releases/download/v${LEVELFOUR_CLI_VERSION}/levelfour-cli_${LEVELFOUR_CLI_VERSION}_linux_amd64.tar.gz \ | tar xz -C /usr/local/bin levelfour l4 ``` For a workflow that runs `l4`, see [CI/CD integration](/guides/ci-cd). ## Next * [CLI authentication](/cli/authentication) is how `l4` gets a credential, and which commands need a read-write key * [Commands](/cli/commands/costs) is the per-command reference, starting with `l4 costs` * [Recipes](/cli/recipes) has the end-to-end shell pipelines --- # Authentication Source: https://docs.levelfour.ai/cli/authentication.md `l4` resolves credentials in a fixed order on every invocation: 1. The `--token` / `-t` flag 2. The `LEVELFOUR_TOKEN` environment variable 3. The OS keychain (populated by `l4 auth login`, interactive use only) The first match wins. If none yields a token, the command exits with code `4` (`ExitAuthRequired`) and prints a hint. ## Interactive: browser login On your own machine, sign in through the browser: ```bash l4 auth login ``` This opens your default browser to the LevelFour sign-in page, completes the device-code exchange, and stores the resulting token in the OS keychain (macOS Keychain, Linux Secret Service via `libsecret`, Windows Credential Vault). Later commands pick it up from there, and you pass nothing. To force a fresh session even if you are already logged in: ```bash l4 auth login --force ``` To clear the stored token: ```bash l4 auth logout ``` Confirm the login landed with `l4 whoami`. [Inspect the active session](#inspect-the-active-session) has what a good answer looks like. ## Browser login mints a read-only key The key `l4 auth login` creates has the `read` scope. That covers the read commands: `costs`, `recommendations list`, `recommendations view`, `integrations`, `status`, `estimate`, `diff`, `export`, and `whoami`. `l4 api` reaches whatever the key's scope allows, so it works for reads and fails on any endpoint that requires `read-write`. The write commands ([`rec accept`](/cli/commands/recommendations/accept), [`rec reject`](/cli/commands/recommendations/reject), [`rec execute`](/cli/commands/recommendations/execute)) change state on a recommendation, so they need a `read-write` key. Browser login will not give you one. Create it in the dashboard under **Settings > API Keys**, choose the **Read & write** scope, and pass it explicitly: ```bash export LEVELFOUR_TOKEN="l4_live_your_read_write_key" l4 rec accept REC-1234 ``` > **Error** > > A `read` key that calls a write command fails with `403` and the hint `permission denied: this API key lacks permission for this operation`. [Key scopes](/authentication#key-scopes) has the full read versus read-write split. > **Tip** > > Keep the read-write key out of anything that only reads. Use a `read` key for monitoring, dashboards, and CI checks, and reserve the read-write key for the job that applies changes. ## CI / scripting: `LEVELFOUR_TOKEN` For unattended environments, set `LEVELFOUR_TOKEN` from a secret: ```bash export LEVELFOUR_TOKEN="l4_live_..." l4 recommendations list --json ``` In GitHub Actions, as a step inside a job: ```yaml - name: Run LevelFour CLI env: LEVELFOUR_TOKEN: ${{ secrets.LEVELFOUR_TOKEN }} run: l4 recommendations list --status available --jq '.data.data.items[] | .recommendation_id' ``` The token never touches the filesystem in this mode. [CI/CD integration](/guides/ci-cd) has complete workflows that wire the same secret. ## One-shot override: `--token` For ad-hoc commands or running as a different identity: ```bash l4 --token "l4_live_..." recommendations list ``` The flag wins over both the env var and the keychain. Reach for it when you hold several keys, service accounts among them, and do not want to run `l4 auth login` again. ## Inspect the active session ```bash l4 whoami # identity, organization, role, connected accounts l4 auth status # which credential source is active l4 auth status --verify # additionally validate the token against the API ``` > **Success** > > A `whoami` that names your identity and organization is the proof the credential resolved. `l4 whoami` prints the scope of the active credential, so you can tell a read key from a read-write one before running a write command. > **Warning** > > `l4 auth status` names the credential source without calling the API. Pass `--verify` when you need to know the token still works, or the check passes on a revoked key and the script carries on. ## Creating and rotating keys Create or rotate keys in **Settings > API Keys** in the dashboard. [API keys](/account/api-keys) walks through both. [Key formats](/authentication#key-formats) covers the `l4_live_` and `l4_test_` prefixes and which environment each one reaches. ## Switching API hosts Point at a non-default API base URL with `--api` or `LEVELFOUR_API`: ```bash l4 --api https://api.staging.levelfour.ai whoami ``` The same auth resolution rules apply. Only the base URL changes. To make one stick across commands, store it as the `api_base` key with [`l4 config`](/cli/commands/config). ## Troubleshooting | Symptom | Likely cause | Fix | | -------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------- | | Exit `4`, "not authenticated" | No token resolvable | Run `l4 auth login` or set `LEVELFOUR_TOKEN` | | Exit `1`, "401 Unauthorized" | Token revoked or wrong environment (test vs live) | Rotate the key, or use `l4 --token ...` to test the new one | | Exit `1`, "403 permission denied" on `rec accept` / `rec reject` / `rec execute` | The key has the `read` scope | Create a **Read & write** key in **Settings > API Keys** | | Browser does not open on `auth login` | No `BROWSER` resolved | Open the printed URL manually; the CLI keeps polling | | Keychain access denied on macOS | Keychain locked | Unlock the login keychain in **Keychain Access** | ## Next * [CLI overview](/cli) owns the exit codes and the full command catalogue * [Installation](/cli/installation) if `l4` is not on this machine yet * [CI/CD integration](/guides/ci-cd) has the GitHub Actions and GitLab CI workflows in full * [Connecting a client](/mcp/authentication) takes these same keys, or signs you in instead --- # Output formats Source: https://docs.levelfour.ai/cli/output-formats.md Every command takes the same set of global output flags. With none of them you get a human-readable table, colored for the terminal. | Flag | Output | | ------------------ | -------------------------------------------------- | | (default) | Human-readable, colored, table-formatted | | `--json` | Pretty-printed JSON | | `--jq ` | JSON filtered through a `jq` expression in-process | | `--template ` | Output formatted with a Go `text/template` | | `--csv` | CSV (only honored by `l4 export *` commands) | | `--quiet` / `-q` | No output; the exit code is the signal | | `--no-color` | Disable ANSI colors (also `NO_COLOR=1`) | | `--web` / `-w` | Open the equivalent dashboard URL instead | > **Note** > > `--quiet` is mutually exclusive with `--json`, `--jq`, `--template` and `--csv`. Combining them is a usage error. ## `--json` Use `--json` to pipe into another tool, or when you want a shape that will not move under you. The JSON envelope is the same shape returned by the underlying API: ```bash l4 recommendations list --json ``` ```json { "data": { "success": true, "data": { "total_savings": 23545.31, "items": ["..."], "pagination": { "total_items": 204, "total_pages": 41, "current_page": 1, "page_size": 5, "has_next": true, "has_previous": false } }, "timestamp": "2026-04-27T11:00:00Z" } } ``` The values above are invented. The field names and their nesting are the ones the CLI emits, and [Pagination](/pagination) covers the envelope fields inside `data`. JSON paths are stable across releases. Script against them. ## `--jq` `--jq` filters the JSON output through an embedded `jq` expression in a single process. It runs faster than spawning `jq` separately, and it works where `jq` is not installed: ```bash l4 recommendations list --jq '.data.data.items[] | {id: .recommendation_id, savings: .monthly_savings}' ``` Equivalent to: ```bash l4 recommendations list --json | jq '.data.data.items[] | {id: .recommendation_id, savings: .monthly_savings}' ``` For complex multi-step pipelines (slurps, branches, table formatting), prefer the explicit pipe so each stage is debuggable. See [Recipes](/cli/recipes). [Filtering and sorting](/guides/filtering-and-sorting) has the quirks worth knowing before you write one: what `--jq` will not do, and why the envelope path is not the same on every command. ## `--template` Format the output with a Go `text/template` when a downstream tool wants a plain-text shape that is not quite CSV: ```bash l4 costs summary --template '{{ range .data.providers }}{{ .provider_name }}: ${{ .monthly_spending }}/mo{{ "\n" }}{{ end }}' ``` ## `--csv` > **Warning** > > Only the `l4 export *` commands honor `--csv`. On other commands a warning is printed and the format falls back to the default, so redirecting one into a `.csv` leaves a table in the file. Use `l4 export` for spreadsheet imports: ```bash l4 export costs --period 90d --format csv > costs.csv ``` [`l4 export`](/cli/commands/export) has the rest, including `--out` and the recommendations export. ## `--quiet` `--quiet` suppresses all stdout and stderr. Use it as a boolean check or as a CI guardrail: ```bash l4 estimate --fail-above 500 -q ./infra/ && echo "Within budget" || echo "Over budget" ``` > **Note** > > `--quiet` prints nothing at all, on success and on failure alike, so read the result from `$?` against the [exit codes](/cli#exit-codes). [`l4 estimate`](/cli/commands/estimate#ci-guardrail-pattern) has the CI guardrail this example comes from. ## `--web` Skip the terminal output and open the equivalent page in the dashboard: ```bash l4 recommendations view REC-001 --web ``` The CLI computes the dashboard URL by replacing `api.` with `dashboard.` in the active API base. [`l4 recommendations view`](/cli/commands/recommendations) has the command this example comes from. ## `--no-color` Either of these turns color off: ```bash l4 costs summary --no-color NO_COLOR=1 l4 costs summary ``` The CLI also drops color when stdout is not a TTY, piped or redirected, so neither case needs the flag. ## Next * [Filtering and sorting](/guides/filtering-and-sorting) has the server-side filter flags, the TUI and the `--jq` surface in one place * [Recipes](/cli/recipes) has the multi-step pipelines these flags feed * [`l4 export`](/cli/commands/export) is where CSV comes from * [CLI overview](/cli#exit-codes) has the exit codes `--quiet` leans on --- # l4 costs Source: https://docs.levelfour.ai/cli/commands/costs.md `l4 costs` reads cloud spend from the terminal. ## Subcommands | Command | Purpose | | ------------------------- | -------------------------------------------------------------------------- | | [`summary`](#summary) | KPI overview: monthly spend, forecast, total potential savings | | [`breakdown`](#breakdown) | Per-service breakdown with filters, multi-dimensional grouping, pagination | | [`daily`](#daily) | Daily costs aggregated per day for a date range | | [`monthly`](#monthly) | Monthly costs aggregated per month | | [`filters`](#filters) | Distinct values for every filter dimension | ## summary ```bash l4 costs summary [--provider ] ``` Prints the top-line KPIs: monthly spend, forecast and total potential savings. Pass `--provider` to scope to one cloud (`aws`, `gcp`, `azure`, `k8s`). ```bash l4 costs summary l4 costs summary --provider aws l4 costs summary --json ``` ## breakdown ```bash l4 costs breakdown [flags] ``` `--tui` opens the same rows in an interactive split-pane viewer, where you drill into a row and pivot grouping live. [Filtering and sorting](/guides/filtering-and-sorting#interactive-tui) lists its keys and shows how flags passed on the command line seed its starting state. ### Flags | Flag | Description | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--provider ` | `aws`, `gcp`, `azure`, `k8s` (auto-detected if omitted) | | `--start ` / `--end ` | Date range, ISO 8601 | | `--preset ` | `30D`, `6M`, `12M` (overrides `--start`/`--end`) | | `--granularity ` | `daily` or `monthly` | | `--group-by ` | Repeatable: `service`, `account_id`, `region`, `tag`, `virtual_tag` | | `--service ` | Repeatable filter | | `--region ` | Repeatable filter | | `--account ` | Repeatable filter | | `--environment ` | Repeatable filter | | `--tag-key ` / `--tag-value ` | Required when `--group-by` includes `tag` | | `--virtual-tag-key ` | A virtual tag's name or id. Reports the spend its rules assigned rather than the raw bill. Required when `--group-by` includes `virtual_tag`, and one key per call | | `--virtual-tag-value ` | Repeatable. Keeps only these values of `--virtual-tag-key`, so `--group-by` then splits those alone. `__unallocated__` keeps the spend no rule assigned | | `--page` / `--page-size` | Pagination (max 100 per page) | | `--sort-by ` | `cost`, `previous_cost`, `change_percentage`, `service`, `region`, `account_id` | | `--sort-by-date ` | Sort by a specific date column (overrides `--sort-by`) | | `--sort-order` | `asc` or `desc` | | `--format` | `table`, `csv`, `raw` (default `table`) | | `--tui` | Open the interactive split-pane viewer | > **Note** > > `--page` and `--page-size` apply to `--format table`. `--format csv` and `--format raw` return the whole filtered set in one response. ### Examples ```bash l4 costs breakdown l4 costs breakdown --preset 30D l4 costs breakdown --start 2026-01-01 --end 2026-01-31 l4 costs breakdown --group-by service --group-by region l4 costs breakdown --service EC2 --service RDS l4 costs breakdown --group-by tag --tag-key Environment --tag-value production l4 costs breakdown --group-by virtual_tag --virtual-tag-key Teams l4 costs breakdown --group-by service --virtual-tag-key Teams --virtual-tag-value data l4 costs breakdown --granularity monthly --preset 6M l4 costs breakdown --sort-by-date 2026-01-15 --sort-order desc l4 costs breakdown --format csv > costs.csv ``` > **Note** > > Filter options cross-constrain each other, matching AWS Cost Explorer behavior: selecting a service narrows the regions and accounts you can subsequently filter on. [`l4 costs filters`](#filters) prints the current option list at any point. ## daily ```bash l4 costs daily [--start ] [--end ] [--provider ] ``` Without `--start` and `--end`, the range is the trailing 30 days. ```bash l4 costs daily l4 costs daily --start 2026-01-01 --end 2026-01-31 l4 costs daily --json | jq '.data.data_points[]' ``` ## monthly ```bash l4 costs monthly [--provider ] ``` One row per month, covering all available history. ```bash l4 costs monthly l4 costs monthly --json | jq '.data.data_points[]' ``` ## filters ```bash l4 costs filters [dimension] [--start ] [--end ] [--provider ] ``` The dimensions are services, regions, accounts, tag keys and tag values. Without an argument, `filters` prints every one. Pass a dimension name to print just that one. ```bash l4 costs filters l4 costs filters service l4 costs filters region --start 2026-01-01 --end 2026-01-31 l4 costs filters --json | jq '.data.services[]' ``` ## Related * [Filtering and sorting](/guides/filtering-and-sorting): these flags in use, the TUI keys, `--jq` pipelines, and the SDK equivalents * [Output formats](/cli/output-formats): the global `--json`, `--jq`, `--template` and `--quiet` flags * [`l4 integrations`](/cli/commands/integrations): which `--provider` values your organization actually has * [Recipes](/cli/recipes): multi-step shell pipelines, mostly over `l4 recommendations` * [`/api-reference/costs`](/api-reference/costs): the underlying REST endpoints --- # Overview Source: https://docs.levelfour.ai/cli/commands/recommendations.md `l4 recommendations` lists pending savings recommendations and prints the full record for one. `rec` and `recs` are aliases for `recommendations`, so `l4 rec accept REC-1234` and `l4 recommendations accept REC-1234` are the same command. ## Subcommands | Command | Purpose | Scope | | -------------------------------------------------- | ----------------------------------------------- | ------------ | | [`list`](#list) | Paginated, filterable list of recommendations | `read` | | [`view`](#view) | Full detail for one recommendation | `read` | | [`accept`](/cli/commands/recommendations/accept) | Record a decision to make the change | `read-write` | | [`reject`](/cli/commands/recommendations/reject) | Close it, with an optional reason | `read-write` | | [`execute`](/cli/commands/recommendations/execute) | Request execution of an accepted recommendation | `read-write` | ## list ```bash l4 recommendations list [flags] ``` ### Flags | Flag | Description | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `--provider ` | `aws`, `gcp`, `azure`, `k8s` (auto-detected if omitted) | | `--service ` | Repeatable filter | | `--account ` | Repeatable filter | | `--environment ` | Repeatable filter | | `--tag ` | Repeatable filter | | `--status ` | Repeatable: `available`, `pending`, `processing`, `optimized`, `rejected`, `unavailable` | | `--search ` | Free-text search within current page | | `--sort-by ` | `recommendation_id`, `service`, `environment`, `account`, `tag`, `monthly_savings`, `savings_percentage`, `status` | | `--sort-order` | `asc` or `desc` | | `--page ` / `--page-size ` | Pagination. Defaults to 20 per page, 100 is the maximum | | `--tui` | Open the interactive viewer | Global flags apply. See [CLI overview](/cli#global-flags). > **Warning** > > `--search` matches against the table the CLI prints, and only against the rows already on the page. A recommendation on page 4 stays invisible while you read page 1. It is also dropped under `--json`, `--jq` and `--template`, which return the whole page unfiltered, so a script that relies on it silently gets more rows than it asked for. Filter with `--service`, `--status`, `--account`, `--environment` or `--tag` instead: those go to the API. `--tui` opens the list in the interactive viewer, with KPI cards and live filtering. [Filtering and sorting](/guides/filtering-and-sorting#interactive-tui) has its keys. ### Examples ```bash l4 recommendations list l4 recommendations list --service RDS --status available l4 recommendations list --sort-by monthly_savings --sort-order desc l4 recommendations list --page 2 --page-size 10 l4 recommendations list --provider gcp --environment production l4 recommendations list --status available --jq '.data.data.items[] | .recommendation_id' ``` ### JSON shape (relevant paths) ```text .data.data.total_savings # sum across the page .data.data.items[] # array of recommendation rows .data.data.items[].recommendation_id .data.data.items[].service .data.data.items[].monthly_savings .data.data.items[].savings_percentage .data.data.items[].status .data.data.pagination.total_pages # for pagination loops .data.data.pagination.has_next ``` To walk every page and total the result, use [Aggregate 100% savings recommendations](/cli/recipes#aggregate-100-savings-recommendations). ## view ```bash l4 recommendations view [--tui] ``` `l4 recommendations view` prints the full record for one recommendation: description, monthly/annual savings, comparison against current spend, implementation steps, risk and rollback notes. ### Examples ```bash l4 recommendations view REC-001 l4 recommendations view REC-001 --tui l4 recommendations view REC-001 --json l4 recommendations view REC-001 --web ``` In the TUI, use `Tab` / `Shift+Tab` to switch between Recommendation / Comparison / Risk panes, and the arrow keys to scroll within each tab. Piping ids from `list` into `view` is [Fetch full detail for the top five recommendations](/cli/recipes#fetch-full-detail-for-the-top-five-recommendations). ## Related * [Recipes](/cli/recipes): the multi-page pipelines built on `list` * [Filtering and sorting](/guides/filtering-and-sorting): the TUI keys and the `--jq` quirks * [Recommendations API](/api-reference/recommendations): the endpoints underneath --- # l4 rec accept Source: https://docs.levelfour.ai/cli/commands/recommendations/accept.md `l4 rec accept` records an accept decision on a savings recommendation. It moves the recommendation out of the Available bucket and into Pending, stamps who accepted it and when, and leaves it ready to execute. ```bash l4 rec accept [flags] ``` `rec` and `recs` are [aliases for `recommendations`](/cli/commands/recommendations). > **Note** > > Nothing is applied in your cloud account. Accepting records a decision. [`l4 rec execute`](/cli/commands/recommendations/execute) is what starts the change. ## What it changes The command posts to `/api/v1/recommendations/{id}/decision` with `{"decision": "accepted"}`. On the recommendation: * `saving_acceptance` becomes `accepted` * `saving_accepted_by` and `saving_accepted_at` are stamped from the credential * `status` itself stays `pending`, so the dashboard shows the recommendation under Pending rather than Available * if a member requested execution and the recommendation is waiting on an approver, accepting it resolves that request and records you as the approver ## Flags | Flag | Description | | -------------- | ---------------------------- | | `--yes` / `-y` | Skip the confirmation prompt | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## Confirmation By default the command prompts before sending: ```text Accept recommendation REC-1234? [y/N]: ``` Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. Pass `--yes` to skip the prompt in an interactive shell. > **Warning** > > When stdout is not a TTY, so in a pipeline or a CI job, the prompt never appears and the write goes through. That stops a scripted run blocking on input that will not arrive, and it also means every id you pipe in is accepted with nothing to confirm. ## Requires a read-write key The endpoint requires the `read-write` scope, and `l4 auth login` mints a read-only key. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create one that has it. ## Re-running it Each invocation sends its own `Idempotency-Key`, which lets the API discard a duplicate delivery of the same request. A deliberate re-run is a new request: it re-stamps the same decision fields and adds another entry to the recommendation's audit timeline. ## Examples ```bash l4 rec accept REC-1234 l4 rec accept REC-1234 --yes l4 rec accept REC-1234 --json LEVELFOUR_TOKEN=$L4_RW_KEY l4 rec accept REC-1234 --yes --quiet ``` Accept the available recommendations above a savings threshold, one page of results per run: ```bash l4 rec list --status available --page-size 100 --jq '.data.data.items[] | select(.monthly_savings > 500) | .recommendation_id' \ | xargs -I{} l4 rec accept {} --yes ``` ## Output Default output is a success line plus the fields the API returned: ```text Recommendation REC-1234 accepted By api-key:00000000-0000-0000-0000-000000000000 At 2026-08-22 ``` An API key stamps `saving_accepted_by` as `api-key:`, the UUID of the key that made the call. The same decision made in the dashboard stamps the signed-in user's email instead. With `--json` the whole API envelope is printed. The fields it carries: ```text .success .data.recommendation_id .data.saving_acceptance # "accepted" .data.saving_accepted_by .data.saving_accepted_at .data.status # "pending" .timestamp ``` > **Success** > > `saving_acceptance` reading `accepted` is the confirmation the decision landed. `status` staying `pending` is the expected result, not a failed write. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | ---------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `403` permission denied | The key has the `read` scope, or your role lacks `org:savings:execute` | | `1` | `404` | No recommendation with that id in this organization | ## Related * [`l4 rec reject`](/cli/commands/recommendations/reject): the other half of the decision * [`l4 rec execute`](/cli/commands/recommendations/execute): request execution of an accepted recommendation * [`l4 recommendations`](/cli/commands/recommendations): list and view recommendations * [CLI authentication](/cli/authentication): read versus read-write keys --- # l4 rec reject Source: https://docs.levelfour.ai/cli/commands/recommendations/reject.md `l4 rec reject` records a reject decision on a savings recommendation. The recommendation's status becomes `rejected`. The reason is optional, and you can add or change it later in the dashboard. ```bash l4 rec reject [flags] ``` `rec` and `recs` are [aliases for `recommendations`](/cli/commands/recommendations). ## What it changes The command posts to `/api/v1/recommendations/{id}/decision` with `{"decision": "rejected"}` plus the `--reason` and `--explanation` values you passed. On the recommendation: * `status` becomes `rejected` * `saving_acceptance` becomes `rejected`, stamped with your identity and the time * `rejection_reason` and `rejection_explanation` are set when you pass them, and stay `null` when you do not * if a member had requested execution and left the recommendation awaiting approval, the reject resolves that request ## Flags | Flag | Description | | ---------------------- | --------------------------------------------------------------------- | | `--reason ` | One of `operational`, `strategy`, `not_applicable`, `other`. Optional | | `--explanation ` | Free-text explanation, used when `--reason` is `other` | | `--yes` / `-y` | Skip the confirmation prompt | `l4` checks `--reason` before it sends anything. A value outside that set fails locally, and the error prints the ones it accepts. The API enforces the same set. Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## Confirmation By default the command prompts before sending: ```text Reject recommendation REC-1234? [y/N]: ``` Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. Pass `--yes` to skip the prompt in an interactive shell. > **Warning** > > Two things send the decision with no prompt: `--yes`, and a stdout that is not a TTY. A piped or scheduled run closes the recommendation without asking, so check the id before you put this in a pipeline or a CI job. ## Requires a read-write key The endpoint requires the `read-write` scope, and `l4 auth login` mints a read-only key. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create one that has it. ## Re-running it Each invocation sends its own `Idempotency-Key`, which lets the API discard a duplicate delivery of the same request. A deliberate re-run is a new request: it re-stamps the same decision fields and adds another entry to the recommendation's audit timeline. ## Examples ```bash l4 rec reject REC-1234 l4 rec reject REC-1234 --reason operational l4 rec reject REC-1234 --reason other --explanation "Owned by a team that is migrating off" l4 rec reject REC-1234 --reason not_applicable --yes --json ``` ## Output Default output is a success line plus the fields the API returned: ```text Recommendation REC-1234 rejected Reason operational ``` With `--json` the whole API envelope is printed. The fields it carries: ```text .success .data.recommendation_id .data.saving_acceptance # "rejected" .data.status # "rejected" .data.rejection_reason .data.rejection_explanation .timestamp ``` > **Success** > > `Recommendation REC-1234 rejected` is the proof the decision landed. Find the recommendation again with [`l4 rec list --status rejected`](/cli/commands/recommendations#list). ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | ---------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `invalid --reason` | The value is not one of the reasons the API accepts | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `403` permission denied | The key has the `read` scope, or your role lacks `org:savings:execute` | | `1` | `404` | No recommendation with that id in this organization | ## Related * [`l4 rec accept`](/cli/commands/recommendations/accept): the other half of the decision * [`l4 rec execute`](/cli/commands/recommendations/execute): request execution of an accepted recommendation * [`l4 recommendations`](/cli/commands/recommendations): list and view recommendations * [CLI authentication](/cli/authentication): read versus read-write keys --- # l4 rec execute Source: https://docs.levelfour.ai/cli/commands/recommendations/execute.md `l4 rec execute` requests execution of a savings recommendation that has already been accepted. It records the implementation method and marks the rollout in progress. The rollout itself runs server-side. ```bash l4 rec execute [flags] ``` `rec` and `recs` are [aliases for `recommendations`](/cli/commands/recommendations). > **Note** > > Accept the recommendation first with [`l4 rec accept`](/cli/commands/recommendations/accept). The `one-click` methods apply the change through the connected [Automated Savings](/connectors/aws/automated-savings) grant, and connecting that grant happens in the dashboard, not from the CLI. ## What it changes The command posts to `/api/v1/recommendations/audit/execution-requests` with the recommendation id and the chosen `implementation_method`. On the recommendation: * `implementation_status` becomes `in_progress`, while `status` stays `pending`, so the dashboard keeps the recommendation under Pending with the Implementation column live * `implementation_method` is set to the value you passed * the dashboard's Triggered Via card records the method plus `saving_accepted_by`, the identity that accepted the recommendation rather than the credential that ran this command ## Flags | Flag | Description | | -------------- | ------------------------------------------------------------------------------ | | `--method ` | `one-click`, `iac`, `one-click-plus-iac`, or `manual`. Defaults to `one-click` | | `--yes` / `-y` | Skip the confirmation prompt | `l4` rejects an unrecognized `--method` locally, before it sends anything. The API enforces the same set. Global flags apply. See [CLI overview](/cli#global-flags). ## Methods | Method | Meaning | | -------------------- | ------------------------------------------------------------------------------------------------------- | | `one-click` | LevelFour applies the change in your cloud account using the connected Automated Savings grant | | `iac` | LevelFour opens a pull request against your infrastructure-as-code repository | | `one-click-plus-iac` | Applies the change and then opens the pull request. Needs both a connected grant and a bound repository | | `manual` | You apply the change yourself. Records the method without triggering an automated rollout | ## Confirmation By default the command prompts before sending, and the prompt names the method: ```text Execute recommendation REC-1234 using the one-click method? [y/N]: ``` Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. Pass `--yes` to skip the prompt in an interactive shell. > **Warning** > > This is the command that starts a change in your cloud account, and two things send it through with no prompt: `--yes`, and a stdout that is not a TTY. Check the recommendation id and the method before you put this in a pipeline or a CI job. ## Requires a read-write key The endpoint requires the `read-write` scope, and `l4 auth login` mints a read-only key. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create one that has it. ## Re-running it Each invocation sends its own `Idempotency-Key`, which lets the API discard a duplicate delivery of the same request. Re-running execute does not start a second rollout either: the server only sets `implementation_status` when it is not already set. ## Examples ```bash l4 rec execute REC-1234 l4 rec execute REC-1234 --method iac l4 rec execute REC-1234 --method manual --yes l4 rec execute REC-1234 --json ``` Accept and execute in one step, with no prompt at either stage: ```bash l4 rec accept REC-1234 --yes && l4 rec execute REC-1234 --method one-click --yes ``` ## Output Default output is a success line plus the fields the API returned: ```text Execution requested for recommendation REC-1234 Status processing Method one-click ``` With `--json` the whole API envelope is printed. The fields it carries: ```text .success .data.recommendation_id .data.status # "processing" .data.implementation_method .timestamp ``` > **Success** > > `Execution requested for recommendation REC-1234` is the proof the request landed. The rollout runs server-side from there. Track it with `l4 recommendations view `, or in the dashboard under Status & Logs. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | --------------------------------------------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `invalid --method` | The value is not one of the methods the API accepts | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `403` permission denied | The key has the `read` scope. The must-be-accepted-first gate applies to dashboard roles, not to API keys | | `1` | `404` | No recommendation with that id in this organization | ## Related * [`l4 rec accept`](/cli/commands/recommendations/accept): accept before executing * [`l4 rec reject`](/cli/commands/recommendations/reject): reject instead * [`l4 recommendations`](/cli/commands/recommendations): list and view recommendations * [CLI authentication](/cli/authentication): read versus read-write keys * [Automated Savings](/connectors/aws/automated-savings): the grant the `one-click` methods use * [Webhooks](/webhooks): optimization lifecycle events --- # Overview Source: https://docs.levelfour.ai/cli/commands/commitments.md `l4 commitments` reads what your organization has already committed to, and what that commitment is earning. It answers the questions the Commitments page answers, plus one it cannot: whether a term is about to lapse, as an exit code a pipeline can act on. `cmt` is an alias, so `l4 cmt list` and `l4 commitments list` are the same command. Every subcommand is read-only. Nothing here buys, cancels or exchanges a commitment. ## Subcommands | Command | Purpose | Scope | | ------------------------------------------------ | ------------------------------------------------------------ | ------ | | [`summary`](#summary) | Coverage, utilization and what the commitments are earning | `read` | | [`list`](#list) | Every commitment held, soonest to expire first | `read` | | [`expiring`](/cli/commands/commitments/expiring) | What lapses soon, with an exit code for CI | `read` | | [`view`](#view) | Everything known about one commitment | `read` | | [`renewal`](/cli/commands/commitments/renewal) | What to repurchase when a term ends, and when to buy | `read` | | [`utilization`](#utilization) | How much of what was bought is being used | `read` | | [`coverage`](/cli/commands/commitments/coverage) | How much of the eligible bill is covered, weighted | `read` | | [`plan`](#plan) | The uncovered on-demand base, and what buying would cover it | `read` | | [`contracts`](#contracts) | Marketplace floors that bill like a commitment | `read` | [`l4 export commitments`](/cli/commands/export#commitments) writes the same ledger as CSV or JSON. ## Which providers answer AWS and Google Cloud both report. They do not report the same things, because the two clouds expose different data. | Command | AWS | Google Cloud | | ------------- | ---- | ----------------------------------------- | | `summary` | full | coverage and utilization, no savings rate | | `list` | full | no expiry | | `view` | full | no term or expiry | | `coverage` | full | a rate, but no covered spend | | `export` | full | no expiry | | `expiring` | full | not measured | | `renewal` | full | not measured | | `utilization` | full | one entry, resource-based discounts only | | `plan` | full | not measured | | `contracts` | full | not measured | Google Cloud has no public on-demand equivalent to divide by, so there is no effective savings rate to compute. Committed Use Discount term and expiry come from an export that Google ships separately, and until it is enabled a Committed Use Discount has no end date on any surface. Where a command cannot answer for the resolved provider it says so in one line and exits `0`: ```text $ l4 commitments contracts --provider gcp Marketplace contracts are measured for AWS only: none are collected for Google Cloud. ``` It does not print an empty table. An empty table reads as "you hold none of these", which is a different and wrong claim. ## `not measured` is not zero Where a figure was never measured, these commands print `not measured` rather than `0`. The distinction is load-bearing in four places: * **A Savings Plan has no coverage.** Cost Explorer cannot filter coverage by plan type, so what one plan covers is unanswerable. The column reads `not measured`. A `0.0%` there would claim the plan covers none of your eligible usage. * **Google Cloud has no savings rate**, for the reason above. `summary` prints `not measured`, never a rate derived from coverage. * **Google Cloud's expiring count is unmeasurable**, not zero. A `0` would read as reassurance. * **Totals come from the API**, never from summing a column. The monthly commitment amount is not populated on every term, so a client-side total would quietly understate the portfolio. ## Picking a provider Every command reports on one provider. Without `--provider`, the CLI resolves the first provider it can answer commitments for rather than the first one connected, so a Google Cloud organization with another provider connected first still lands on its own data. A named `--provider` is always honoured, whatever it is, because "does Azure hold anything" deserves an answer rather than a silent redirect. ## summary ```bash l4 commitments summary [flags] ``` Coverage, the commitment count, how many expire soon, and on AWS the effective savings rate split into the part a negotiated agreement earns and the part commitments earn. Then one row per service, with each instrument measured on its own. The headline coverage figure is the same spend-weighted one [`l4 commitments coverage`](/cli/commands/commitments/coverage) prints, from the same source, so the two commands cannot report different coverage for one estate. ### Flags | Flag | Description | | -------------------- | -------------------------------------------------------------------- | | `--provider ` | `aws` or `gcp` (resolved if omitted) | | `--period ` | Month to measure the rate over. The latest complete month if omitted | | `--scope ` | `eligible` (default: usage a commitment could cover) or `all` | Global flags apply. See [CLI overview](/cli#global-flags). ### Examples ```bash l4 commitments summary l4 commitments summary --provider gcp l4 commitments summary --period 2026-08 --scope all l4 commitments summary --jq '.esr.data.totals.total_rate_pct' ``` The rate is reported alongside the share of spend it could be measured over. Where that share is below 100%, the command says so rather than presenting a rate for the whole bill. ## list ```bash l4 commitments list [flags] ``` One row per commitment across every account, soonest to expire first, with what a renewal protects and what right-sizing would recover kept in separate columns. They are different amounts and are never added: one is the loss avoided by renewing, the other is the saving from buying less. ### Flags | Flag | Description | | ----------------------- | ---------------------------------------------------- | | `--provider ` | `aws` or `gcp` (resolved if omitted) | | `--basis ` | `net` (default, after your agreement) or `list` | | `--kind ` | `ri`, `sp`, `cud`, `spend` | | `--status ` | `active`, `expiring`, `expired`, `cancelled` | | `--expiring-within ` | Only terms ending inside a window, for example `90d` | A kind belonging to another provider is refused rather than sent. `--kind cud --provider aws` is a valid request that returns nothing, and nothing is exactly what reads as "you hold none of these". ### Examples ```bash l4 commitments list l4 commitments list --basis list l4 commitments list --kind ri --expiring-within 90d l4 commitments list --provider gcp l4 commitments list --jq '.data.rows[] | {id, expires: .expires_in_seconds}' ``` > **Warning** > > `--kind`, `--status` and `--expiring-within` shape the table, not the payload. Under `--json`, `--jq` or `--template` the command prints what the API returned, unfiltered, and says on stderr which flags it left out. Narrow the payload with `--jq` instead. The JSON shape differs by provider, because the two clouds return different records. AWS rows arrive under `.data.rows[]`, Google Cloud rows under `.items[]`. A script that reads both has to branch on the provider. ## view ```bash l4 commitments view ``` The full record for one commitment: kind, term, payment option, dates, state, utilization, coverage, the accounts consuming it, and any recommendations attached to it. A Savings Plan id is an ARN, so it contains colons and a slash. Quote it. ```bash l4 commitments view ri-0a1b2c3d l4 commitments view "arn:aws:savingsplans::111122223333:savingsplan/abc" l4 commitments view ri-0a1b2c3d --json l4 commitments view ri-0a1b2c3d --web ``` ## utilization ```bash l4 commitments utilization [flags] ``` Utilization is commitment hours used over commitment hours purchased: am I getting value from what I bought. It is not coverage, which is eligible usage on a commitment over all eligible usage: how exposed am I to on-demand rates. > **Note** > > The two have different denominators and are never averaged together. The valuable reading is the mismatch. Full utilization alongside low coverage means the commitment is undersized, not that it is performing well: every eligible dollar above it is paying the on-demand rate. Each service is measured on its own. EC2 and RDS contract in vCPU while Redshift, ElastiCache, OpenSearch and MemoryDB contract in nodes, so a figure averaged across them would describe none of them. ### Flags | Flag | Description | | --------------------------------------------- | ---------------------------------------------------------------- | | `--instrument ` | `ri` (default) or `sp` | | `--start ` / `--end ` | Window to measure | | `--granularity ` | `daily` (default) or `monthly` | | `--service ` | Restrict to one service, which also prints its day-by-day series | ### Examples ```bash l4 commitments utilization l4 commitments utilization --instrument sp l4 commitments utilization --granularity monthly --start 2026-01-01 l4 commitments utilization --service ec2 ``` A plan's day series carries no coverage column at all, for the reason in [`not measured` is not zero](#not-measured-is-not-zero). ## plan ```bash l4 commitments plan [flags] ``` On-demand spend no commitment covers, split by platform and volatility, beside the purchases that would cover it. Each slice is sized against its hourly floor rather than its average. Sizing against the average of a volatile base commits to more than the base can sustain, which strands part of the commitment for its whole term. | Flag | Description | | ----------------- | ------------------------------------------------------ | | `--provider ` | `aws` (the only provider with an uncovered base today) | | `--format ` | `table` (default) or `csv` | ```bash l4 commitments plan l4 commitments plan --format csv > purchase-plan.csv ``` Buying is a savings recommendation, not a commitments command. `plan` names the recommendation ids, and [`l4 rec accept`](/cli/commands/recommendations/accept) acts on one. ## contracts ```bash l4 commitments contracts ``` Marketplace and private-pricing relationships that bill like a commitment and appear in no provider commitment view, with the contracted floor separated from the metered leg that bills on top of it. The floor is paid whether or not you use it. ## Availability These commands need an API release that serves the commitments routes. Against an organization whose API does not yet, every one of them reports that and exits `0` rather than failing: ```text Commitments are not yet available for your account. ``` ## Related * [`l4 commitments expiring`](/cli/commands/commitments/expiring): the CI gate * [`l4 commitments renewal`](/cli/commands/commitments/renewal): sizing a repurchase * [`l4 export commitments`](/cli/commands/export#commitments): the ledger as CSV or JSON * [`l4 recommendations`](/cli/commands/recommendations): where a purchase is actually decided * [Output formats](/cli/output-formats): `--json`, `--jq`, `--template` and `--quiet` --- # l4 commitments coverage Source: https://docs.levelfour.ai/cli/commands/commitments/coverage.md `l4 commitments coverage` answers how exposed you are to on-demand rates: eligible usage sitting on a commitment, over all eligible usage. ```bash l4 commitments coverage [flags] ``` Every percentage is weighted by the dollars behind it, so a small reservation cannot move the figure as far as a large one. ## Coverage is not utilization The two look similar and answer opposite questions. | | Divides | Answers | | ---------------------------------------------------- | ----------------------------------------------------- | ------------------------------------- | | Coverage | eligible usage on a commitment, by all eligible usage | how exposed am I to on-demand rates | | [Utilization](/cli/commands/commitments#utilization) | commitment hours used, by commitment hours purchased | am I getting value from what I bought | > **Note** > > They have different denominators and are never averaged together. The valuable reading is the mismatch: full utilization alongside low coverage means the commitment is undersized, not that it is performing well, because every eligible dollar above it pays the on-demand rate. ## Flags | Flag | Description | | ------------------ | ------------------------------------------- | | `--instrument ` | Restrict to `ri` or `sp`. Both when omitted | | `--provider ` | `aws` or `gcp` (resolved if omitted) | Global flags apply. See [CLI overview](/cli#global-flags). ## Output ```text Covered 80.8% Covered spend $41200.00/mo Uncovered spend $24800.00/mo By service RI Amazon Redshift 95.0% 2026-09-18 RI Amazon Relational Database Service 66.6% 2026-09-18 SP compute 54.9% not measured ``` The covered and uncovered dollars sit beside the percentage so it names its own denominator. A reservation's row is a service; a plan's row is a plan type, which has no billed service name of its own and so reads as its key. `Measured on` is the date the figure was last measured, not today. A commitment sweep runs daily. ## What it will not tell you A sweep that has not run says so, rather than reporting nothing covered: ```text Coverage is not measured for AWS yet. ``` Those are opposite answers, and a `0.0%` would merge them. The same discipline applies inside the table: see [`not measured` is not zero](/cli/commands/commitments#not-measured-is-not-zero). Google Cloud reports a coverage percentage but no covered spend. The only dollar figure stored beside its rate is the commitment fee, and reporting that as covered spend would claim more covered spend than there was bill to cover. The cell reads `not measured`. ## Why this replaced an older number An earlier coverage read averaged percentages across the commitments in a service, so a hundred dollar reservation moved the figure as far as a fifty thousand dollar one. It also selected one row per commitment held, which meant usage that no commitment covers had no row and could not appear, and that is the larger half of coverage. This command reads a spend-weighted figure with the uncovered side measured. [`l4 commitments summary`](/cli/commands/commitments#summary) shows the same number, from the same source, so the two cannot disagree about one estate. ## Examples ```bash l4 commitments coverage l4 commitments coverage --instrument sp l4 commitments coverage --provider gcp l4 commitments coverage --jq '.data.totals.coverage_pct' ``` `--json` prints the untouched payload, which carries two blocks the table leaves out: covered spend per consuming account, and the uncovered on-demand base per service. ## Related * [`l4 commitments`](/cli/commands/commitments): the rest of the group * [`l4 commitments plan`](/cli/commands/commitments#plan): what buying would cover the uncovered base * [`l4 commitments utilization`](/cli/commands/commitments#utilization): the other half of the pair --- # l4 commitments expiring Source: https://docs.levelfour.ai/cli/commands/commitments/expiring.md `l4 commitments expiring` lists the commitments whose term ends inside a window. With `--fail-within` it also returns exit code `2`, which is what makes it usable as a build gate. ```bash l4 commitments expiring [flags] ``` A term ending is not an event anything currently interrupts. The commitment simply stops, and the usage it was covering reverts to on-demand rates on the next bill. This command is how that becomes something a pipeline notices. ## Flags | Flag | Description | | ------------------- | ---------------------------------------------------------------------- | | `--within ` | Window to list. Defaults to `90d` | | `--fail-within ` | Exit `2` when a commitment lapses inside this window. Unset by default | | `--provider ` | `aws` (expiry is not measured elsewhere) | Durations take `d`, `w` or `m`. A month is 30 days. Global flags apply. See [CLI overview](/cli#global-flags). ## The gate Without `--fail-within` the command only reports, and always exits `0`. ```bash l4 commitments expiring --fail-within 30d ``` ```text ID SERVICE KIND HOLDER EXPIRES UTIL COVERAGE PROTECTS/MO ri-a1b2 EC2 RI prod 18d 98.2% 61.4% $12400.00 Error: 1 commitment(s) lapse within 30d. ``` ```bash $ echo $? 2 ``` Exit `2` is this CLI's "issues found" code, shared with [`l4 estimate --fail-above`](/cli/commands/estimate) and [`l4 diff --fail-above`](/cli/commands/diff#ci-gate). It means the command ran correctly and did not like what it found, which is distinct from `1`, a command that failed. ### The listing widens to match Passing only `--fail-within` widens the listing to the same window, so the output explains the exit code it just produced. Pass `--within` as well to keep a wider view than the gate: ```bash l4 commitments expiring --within 180d --fail-within 30d ``` That lists six months of upcoming expiries and fails on the next thirty days. ### It survives the output flags The gate runs whatever the output format is. Under `--json`, `--jq` or `--template` the payload goes to stdout and both the pass and fail notices go to stderr, so the document stays parseable and the exit code still arrives: ```bash l4 commitments expiring --fail-within 30d --json > expiring.json echo $? ``` > **Warning** > > This is worth checking if you fork this behaviour anywhere. A gate that stops firing the moment someone adds `--json` is the failure the gate exists to prevent, and it is invisible in any test that only exercises the happy path. Under `--quiet` nothing prints at all and the exit code is the whole result, which is the documented contract for that flag. ## In a pipeline GitHub Actions: ```yaml - name: Commitment expiry check env: LEVELFOUR_TOKEN: ${{ secrets.LEVELFOUR_TOKEN }} run: l4 commitments expiring --fail-within 30d ``` GitLab CI: ```yaml commitment-expiry: script: - l4 commitments expiring --fail-within 30d ``` A read-only key is enough. See [CLI authentication](/cli/authentication). Run it on a schedule rather than on every push. Nothing about a commitment's term changes between two commits, and a daily or weekly job gives the thirty days of warning the gate is asking for. ## Providers that cannot be checked Commitment expiry is measured for AWS. Elsewhere the command names why and exits `0`: ```text $ l4 commitments expiring --fail-within 30d --provider gcp Commitment expiry is not measured for Google Cloud: the commitment lifecycle export is not enabled. Nothing checked. $ echo $? 0 ``` > **Note** > > Exiting `0` there is deliberate. A gate that cannot measure must not fail a build for ever. The line says nothing was checked, so an unmeasurable provider is visible in the log rather than silently passing. ## Errors | Exit | Symptom | Cause | | ---- | ----------------------------------------------------- | -------------------------------------------------------------------------- | | `2` | `N commitment(s) lapse within ` | The gate fired. This is the intended result, not a fault | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `invalid window` | `--within` or `--fail-within` was not a number followed by `d`, `w` or `m` | | `0` | `Commitments are not yet available for your account.` | The API does not serve the commitments routes yet | ## Related * [`l4 commitments renewal`](/cli/commands/commitments/renewal): what to buy once the gate fires * [`l4 commitments`](/cli/commands/commitments): the rest of the group * [`l4 diff`](/cli/commands/diff#ci-gate): the other gate in this CLI * [CI/CD integration](/guides/ci-cd): wiring a gate into a pipeline --- # l4 commitments renewal Source: https://docs.levelfour.ai/cli/commands/commitments/renewal.md `l4 commitments renewal` answers what to buy when a term ends: how many units you hold, how many you actually consumed, how many to buy back, and the moment to buy after. ```bash l4 commitments renewal [flags] ``` Nothing here purchases anything. It drafts a plan for a person to execute. The purchase itself is raised in the dashboard and goes through approval like any other saving; [Renewals](/commitments/renewals) walks through it. ## Flags | Flag | Description | | -------------- | -------------------------- | | `--format ` | `table` (default) or `csv` | Global flags apply. See [CLI overview](/cli#global-flags). ## An instant, not a date The plan reports **buy after (UTC)**, an instant rather than a day. A replacement commitment bills from the moment it is bought, so a replacement bought on the right day but before the old term ends is paid for twice for the overlap. The instant is the end of the existing term. ## Held against consumed ```text Units held Units consumed Units recommended 40 34 36 ``` Held is what you bought. Consumed is what the usage actually drew. Recommended is what to buy back, which is neither of the first two: renewing what you hold repurchases the unused part for another term, and renewing exactly what you consumed leaves nothing for growth. Two figures sit below, and they are never added: ```text Renewal protects $12400.00/mo Rightsizing recovers $620.00/mo ``` The first is what lapsing would cost you. The second is what buying less would save. One is a loss avoided, the other is a saving, and summing them double-counts the same usage. ## Land the pending changes first Where the API knows of accepted changes that have not yet been applied to the resources this commitment covers, the plan lists them: ```text Land these before renewing ID SERVICE ACCOUNT SAVINGS STATUS REC-1234 ec2 prod $210.00/mo available Accept one with: l4 rec accept ``` Capacity a pending change would release is capacity the renewal must not reserve for another term. A change that shrinks a fleet after the renewal is bought has bought a year of the wrong size. Acting on one is [`l4 rec accept`](/cli/commands/recommendations/accept), which needs a read-write key. ## CSV `--format csv` emits the same columns the dashboard's own download writes, so a plan exported from either surface lands in the same spreadsheet: ```bash l4 commitments renewal ri-0a1b2c3d --format csv > renewal.csv ``` ```text commitment_id,service,holder_account_id,buy_after_utc,units_held,units_consumed,units_recommended,protects_monthly,rightsizing_monthly ``` An absent instant is an empty cell, which is what a spreadsheet reads as no value. ## Examples ```bash l4 commitments renewal ri-0a1b2c3d l4 commitments renewal "arn:aws:savingsplans::111122223333:savingsplan/abc" l4 commitments renewal ri-0a1b2c3d --format csv l4 commitments renewal ri-0a1b2c3d --json ``` A Savings Plan id is an ARN, so quote it. ## Providers that cannot be planned Renewal planning needs a term and an end date. Outside AWS the command names why and exits `0`: ```text Renewal planning is not measured for Google Cloud: the commitment lifecycle export is not enabled. ``` ## Related * [`l4 commitments expiring`](/cli/commands/commitments/expiring): what to run before this one * [`l4 commitments plan`](/cli/commands/commitments#plan): sizing a first purchase rather than a repurchase * [`l4 rec accept`](/cli/commands/recommendations/accept): landing a pending change * [`l4 commitments`](/cli/commands/commitments): the rest of the group --- # Overview Source: https://docs.levelfour.ai/cli/commands/tags.md `l4 tags` reads your organization's tag keys, both the provider keys from your AWS and GCP bills and the virtual keys you defined. It also manages virtual keys from YAML files you keep in version control. [Virtual tags](/guides/virtual-tags) explains how a virtual key picks a value for each cost. ```bash l4 tags [flags] ``` ## Subcommands | Command | Purpose | Scope | | ------------------------------------------- | ------------------------------------------------------------------ | ------------ | | [`list`](/cli/commands/tags/list) | Every key, with its values, resources and spend | `read` | | [`show`](/cli/commands/tags/show) | One key: its rules in order, spend per value, unallocated spend | `read` | | [`coverage`](/cli/commands/tags/coverage) | Tagged share of spend and resources, with and without virtual tags | `read` | | [`resources`](/cli/commands/tags/resources) | Resources carrying a key, and the value each one holds | `read` | | [`costs`](/cli/commands/tags/costs) | Spend per value of a key | `read` | | [`preview`](/cli/commands/tags/preview) | Dry run of a tag file. Stores nothing | `read` | | [`apply`](/cli/commands/tags/apply) | Create or replace a virtual key from a tag file | `read-write` | | [`delete`](/cli/commands/tags/delete) | Delete a virtual key | `read-write` | ## Naming a key `show`, `resources`, `costs` and `delete` take a `` argument: a key ID or a key name. [`l4 tags list`](/cli/commands/tags/list) prints both. A `vtk_` ID belongs to a virtual key and a `ptk_` ID to a provider key. A virtual key can share its name with a provider key, and the ID then picks the one you mean. Two commands narrow that. `delete` refuses a `ptk_` ID, since a provider tag comes from the bill. `costs` takes a name or a `vtk_` ID and reads a `ptk_` ID as a literal tag key, which matches nothing. When the argument is a name, the CLI resolves it against the key list before making the real request, so a name nobody holds fails locally with `no tag key named ...` rather than a `404` from the API. ## Dates `--start` and `--end` take `YYYY-MM-DD` and go together. Without them, a command reads the last 30 complete days. [`l4 tags costs`](/cli/commands/tags/costs) is the exception, and reads the current month to date. Global flags (`--json`, `--jq`, `--template`, `--quiet`) apply to every subcommand. See [CLI overview](/cli#global-flags). ## Examples ```bash l4 tags list --origin virtual l4 tags show Teams l4 tags coverage --provider aws l4 tags preview -f teams.yaml l4 tags apply -f teams.yaml --dry-run ``` ## Related * [Virtual tags](/guides/virtual-tags): evaluation order, worked examples, dimensions, operators and limits * [`l4 costs`](/cli/commands/costs): the cost breakdown, with provider tag filters * [Output formats](/cli/output-formats): what `--json`, `--jq` and `--template` emit * [Tags API reference](/api-reference/tags): the endpoints underneath --- # l4 tags list Source: https://docs.levelfour.ai/cli/commands/tags/list.md `l4 tags list` prints the tag keys in your organization: the provider keys read from your AWS and GCP bills, and the virtual keys you defined. It reads `GET /api/v1/tags/keys`. ```bash l4 tags list [flags] ``` ## Flags | Flag | Description | | --------------------------------- | -------------------------------------------------------------------------- | | `--origin ` | `virtual` or `provider`. Omit for both | | `--provider

` | `aws` or `gcp`. Omit for both | | `--search ` | Match key names. The API runs the search, so it covers every key | | `--start ` / `--end ` | Window for spend and share of spend. See [Dates](/cli/commands/tags#dates) | Global flags apply. See [CLI overview](/cli#global-flags). ## Examples ```bash l4 tags list l4 tags list --origin virtual l4 tags list --provider gcp --search team l4 tags list --start 2026-08-01 --end 2026-08-31 l4 tags list --origin virtual --jq '.data[] | select(.status == "reprocessing") | .name' ``` ## Output Default output is a table with one row per key. Its Shadows column names the provider key a virtual key takes the place of, and reads `-` otherwise, because a key that shadows one changes what every other command reports for that name. With `--json` the whole API envelope is printed, and each row carries: ```text .data[].id # vtk_... for a virtual key, ptk_... for a provider key .data[].name .data[].origin # "virtual" or "provider" .data[].description .data[].providers[] # "aws", "gcp" .data[].value_count .data[].resource_count .data[].spend .data[].spend_share_pct .data[].status # virtual keys: "active", "reprocessing" or "error" .data[].shares_provider_key # true for a virtual key named after a provider key .data[].values[] # .id and .name ``` ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | ----------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | ## Related * [`l4 tags show`](/cli/commands/tags/show): one key in full * [`l4 tags coverage`](/cli/commands/tags/coverage): how much spend the keys cover * [Virtual tags](/guides/virtual-tags): what a virtual key is --- # l4 tags show Source: https://docs.levelfour.ai/cli/commands/tags/show.md `l4 tags show` prints one tag key. For a virtual key, that is its collapsed keys and configs in the order LevelFour evaluates them, next to the spend each value took, the spend nothing matched, the keys that read this one, and the latest reprocessing job. For a provider key, it prints the values and their spend. ```bash l4 tags show [--start --end ] ``` It reads `GET /api/v1/tags/keys/{key_id}`. `` is an ID or a name, as [Naming a key](/cli/commands/tags#naming-a-key) describes. ## Flags | Flag | Description | | --------------------------------- | ------------------------------------------------------- | | `--start ` / `--end ` | Window for spend. See [Dates](/cli/commands/tags#dates) | Global flags apply. See [CLI overview](/cli#global-flags). ## Examples ```bash l4 tags show Teams l4 tags show vtk_3f9a1c l4 tags show team --start 2026-08-01 --end 2026-08-31 l4 tags show Teams --json l4 tags show Teams --jq '.data.values[] | select(.spend > 1000) | .name' ``` ## Output Default output prints the key's settings, its rules in order, and a table of values with their spend. With `--json` the whole API envelope is printed. The fields it carries: ```text .data.id .data.name .data.origin # "virtual" or "provider" .data.description .data.providers[] .data.status # "active", "reprocessing" or "error" .data.can_override .data.effective_from # "YYYY-MM" .data.rules_version .data.collapsed_keys[] # in evaluation order, each with .position .data.configs[] # in evaluation order: .id, .position, .assign, .filter, .time_frame .data.values[] # .id, .name, .spend, .resource_count .data.total_spend .data.unallocated_spend .data.resource_count .data.dependents[] # keys that read this one: .id, .name .data.job # the latest reprocessing job, or null .data.window.start .data.window.end ``` `--json` prints configs as the API stores them. `assign` holds the value, the split or the cost-based source, and `filter.rules[].conditions[]` holds what a [tag file](/cli/commands/tags/apply#file-format) calls `where`. ## Reprocessing jobs After a change, `.data.job` describes the run that reprocesses the key's costs: ```text .data.job.id .data.job.status # "queued", "running", "succeeded", "failed" or "superseded" .data.job.from_period # "YYYY-MM" .data.job.to_period .data.job.periods_done .data.job.periods_total .data.job.error .data.job.created_at .data.job.finished_at ``` A `superseded` job gave way to a newer change, and the newer job carries on from there. Follow a run: ```bash l4 tags show Teams --jq '.data.job | "\(.status) \(.periods_done)/\(.periods_total)"' ``` > **Success** > > `.data.status` back at `active`, with `.data.job.status` at `succeeded`, means every month from `effective_from` carries the current rules. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | ------------------------------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `no tag key named ...` | A name nobody holds. The CLI resolves names against the key list before it asks for the key | | `1` | `404` `tag_not_found` | A `vtk_` or `ptk_` ID nobody holds in this organization | ## Related * [`l4 tags resources`](/cli/commands/tags/resources): the resources behind each value * [`l4 tags apply`](/cli/commands/tags/apply): change the definition * [Virtual tags](/guides/virtual-tags#how-levelfour-picks-a-value): the order the rules run in --- # l4 tags coverage Source: https://docs.levelfour.ai/cli/commands/tags/coverage.md `l4 tags coverage` reports how much of your spend, and how many of your resources, carry a tag. It measures twice: with provider tags alone, and with virtual tags added. The gap between the two is what your virtual keys cover that the bill's own tags miss. ```bash l4 tags coverage [--provider

] [--start --end ] ``` It reads `GET /api/v1/tags/coverage`. ## Flags | Flag | Description | | --------------------------------- | --------------------------------------------- | | `--provider

` | `aws` or `gcp`. Omit for both | | `--start ` / `--end ` | Window. See [Dates](/cli/commands/tags#dates) | Global flags apply. See [CLI overview](/cli#global-flags). ## Examples ```bash l4 tags coverage l4 tags coverage --provider gcp l4 tags coverage --start 2026-08-01 --end 2026-08-31 l4 tags coverage --jq '.data.by_spend.with_virtual_pct' ``` Fail a scheduled job when tags cover less than 90 percent of spend: ```bash pct=$(l4 tags coverage --jq '.data.by_spend.with_virtual_pct') [ "$pct" -ge 90 ] || { echo "Tag coverage is ${pct}%"; exit 1; } ``` ## Output Default output is one row per basis, spend and resources. With `--json` the whole API envelope is printed. The fields it carries: ```text .data.window.start .data.window.end .data.by_spend.total .data.by_spend.provider_tagged .data.by_spend.with_virtual .data.by_spend.provider_tagged_pct .data.by_spend.with_virtual_pct .data.by_resource.total .data.by_resource.provider_tagged .data.by_resource.with_virtual .data.by_resource.provider_tagged_pct .data.by_resource.with_virtual_pct ``` `by_spend` sums cost and `by_resource` counts resources. Both percentages are whole numbers the server rounds once, and the untagged share is `100` minus the tagged one. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | ----------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | ## Related * [`l4 tags list`](/cli/commands/tags/list): spend and share per key * [Virtual tags](/guides/virtual-tags#unallocated-cost): the cost no key assigns --- # l4 tags resources Source: https://docs.levelfour.ai/cli/commands/tags/resources.md `l4 tags resources` lists the resources that carry a key, one page at a time. Each row shows the value the resource holds, which step of the evaluation assigned it, and its spend over the last 30 complete days. ```bash l4 tags resources [flags] ``` It reads `GET /api/v1/tags/keys/{key_id}/resources`. `` is an ID or a name, as [Naming a key](/cli/commands/tags#naming-a-key) describes. ## Flags | Flag | Description | | -------------------------------- | -------------------------------------------------------------------------- | | `--value ` | Only resources holding this value | | `--provider

` | `aws` or `gcp`. Omit for both | | `--search ` | Filter resources by text. The API runs the search, so it covers every page | | `--page ` / `--page-size ` | Pagination | Global flags apply. See [CLI overview](/cli#global-flags). ## Examples ```bash l4 tags resources Teams l4 tags resources Teams --value data l4 tags resources Teams --provider gcp --search bigquery l4 tags resources Teams --page 2 --page-size 50 l4 tags resources Teams --jq '.data.items[] | select(.ratio < 1) | [.name, .value_name, .ratio]' ``` ## Output Default output is a table with one row per resource and value. With `--json` the whole API envelope is printed. The fields it carries: ```text .data.items[].resource_id .data.items[].name # the Name tag when present, otherwise the resource ID .data.items[].type .data.items[].provider .data.items[].account_id .data.items[].value_id .data.items[].value_name .data.items[].value_source # see below .data.items[].ratio # share of the resource's cost this value takes .data.items[].spend .data.pagination.total_pages .data.pagination.has_next ``` `value_source` names the step that assigned the value: | `value_source` | Assigned by | | -------------- | ------------------------------------------------------------------- | | `provider` | The provider's own value, kept or used as the Can Override fallback | | `collapsed` | A collapsed key | | `config` | A config with one value | | `allocation` | A split or cost-based config | A resource that a split divides appears once per value, and each row's `ratio` gives that value's share. [How LevelFour picks a value](/guides/virtual-tags#how-levelfour-picks-a-value) has the steps in order. ## Errors | Exit | Symptom | Cause | | ---- | ---------------------------- | ----------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `no tag key named ...` | A name nobody holds, resolved locally against the key list | | `1` | `... has no value named ...` | The key holds no value by that name. `l4 tags show` lists them | | `1` | `404` `tag_not_found` | A `vtk_` or `ptk_` ID nobody holds | ## Related * [`l4 tags show`](/cli/commands/tags/show): the key's rules and spend per value * [`l4 tags costs`](/cli/commands/tags/costs): spend per value over a chosen window * [Pagination](/pagination): page defaults and limits --- # l4 tags costs Source: https://docs.levelfour.ai/cli/commands/tags/costs.md `l4 tags costs` prints what each value of a key costs, with its share of the total and its split across service categories. It works on provider keys and virtual keys alike, and reports the spend that carries no value for the key on a line of its own. ```bash l4 tags costs [--provider

] [--start --end ] ``` It reads `GET /api/v1/costs/by-tag`, with the key as `tag_key`. `` is a provider tag key's name, or a virtual key's name or `vtk_` ID. A `ptk_` ID is not accepted here: the server reads that parameter as a literal tag key, so an encoded ID matches nothing. ## Flags | Flag | Description | | --------------------------------- | ---------------------------------------------------------------- | | `--provider

` | `aws`, `gcp` or `all`. Defaults to `aws` | | `--start ` / `--end ` | Window, as `YYYY-MM-DD`. Without them, the current month to date | Global flags apply. See [CLI overview](/cli#global-flags). > **Warning** > > `--provider` defaults to `aws`. A key that spans both providers, such as the `Teams` key in [Virtual tags](/guides/virtual-tags#the-whole-file), reports its AWS spend alone until you pass `--provider all`. ## Examples ```bash l4 tags costs Teams --provider all l4 tags costs team l4 tags costs Teams --provider all --start 2026-08-01 --end 2026-08-31 l4 tags costs Teams --provider all --jq '.data.teams[] | [.label, .total]' l4 tags costs Teams --provider all --jq '.data.unmapped.total' ``` ## Output Default output is a table with one row per value, highest spend first, and the unassigned spend below it. With `--json` the whole API envelope is printed. The fields it carries: ```text .data.tag_key .data.origin # provider or virtual .data.teams[].id # the value .data.teams[].label .data.teams[].total .data.teams[].total_pct # share of the total, unassigned spend included .data.teams[].categories # .compute, .storage, .network, .database, .other .data.unmapped.total # spend with no value for the key ``` `teams` is the API's name for the list of values, whatever the key describes. `origin` says which kind of key the name resolved to, which is what settles the last line: for a virtual key the table labels it Unallocated and `unmapped.total` is the [unallocated cost](/guides/virtual-tags#unallocated-cost); for a provider key the table labels it Untagged and the figure is the spend on lines without the tag. A virtual key shadows a provider key of the same name, so the same argument can resolve either way. A split or cost-based config divides a line before this command reads it, so the values still add up to the total. An unknown provider key name returns an empty list with no error, and so does a `ptk_` ID. Check the name with [`l4 tags list`](/cli/commands/tags/list) when a key you expected comes back empty. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------- | ----------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `422` | A date or provider the API does not accept | ## Related * [`l4 tags show`](/cli/commands/tags/show): spend per value next to the rules that assigned it * [`l4 tags resources`](/cli/commands/tags/resources): the resources behind a value * [`l4 costs`](/cli/commands/costs): the cost breakdown by service, region and account * [Virtual tags](/guides/virtual-tags): define the keys this command reads --- # l4 tags preview Source: https://docs.levelfour.ai/cli/commands/tags/preview.md `l4 tags preview` runs a tag file against your recent costs and prints what each value would take. It saves nothing, so you can try a rule, read the result and edit the file as many times as you like before [`l4 tags apply`](/cli/commands/tags/apply). ```bash l4 tags preview -f [--start --end ] ``` The command posts the file to `/api/v1/tags/virtual/preview`. It reads the same [file format](/cli/commands/tags/apply#file-format) as `apply`, and `name` may be empty. > **Note** > > Preview evaluates the file you pass, not the saved key. `effective_from` has no effect here: the window is `--start` to `--end`, or the last 30 complete days, and [Limits](/guides/virtual-tags#limits) caps its length. ## Flags | Flag | Description | | --------------------------------- | --------------------------------------------- | | `-f ` | Tag file to evaluate. Required | | `--start ` / `--end ` | Window. See [Dates](/cli/commands/tags#dates) | Global flags apply. See [CLI overview](/cli#global-flags). ## Examples ```bash l4 tags preview -f teams.yaml l4 tags preview -f teams.yaml --start 2026-08-01 --end 2026-08-31 l4 tags preview -f teams.yaml --jq '.data.unallocated_spend' l4 tags preview -f teams.yaml --jq '.data.values[] | [.name, .spend]' ``` ## Output Default output is a table of values with their spend and resource count, followed by the unallocated and shadowed spend. With `--json` the whole API envelope is printed. The fields it carries: ```text .data.window.start .data.window.end .data.total_spend .data.unallocated_spend .data.shadowed_spend # spend a lower config also matches, already taken by a higher one .data.values[].name .data.values[].spend .data.values[].resource_count .data.values[].sample_resources[] ``` `shadowed_spend` points at order problems. A config you expected to claim spend, showing little of it while `shadowed_spend` is high, sits below a broader config that takes its lines first. Move it up. ## Errors | Exit | Symptom | Cause | | ---- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | The file does not parse, or has an unknown field | Fix the file. [File format](/cli/commands/tags/apply#file-format) lists the fields | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `422` `validation_failed` | The file breaks a rule. [Validation problems](/cli/commands/tags/apply#validation-problems) lists the codes | ## Related * [`l4 tags apply`](/cli/commands/tags/apply): save the file once the preview looks right * [Virtual tags](/guides/virtual-tags): the rules the preview evaluates --- # l4 tags apply Source: https://docs.levelfour.ai/cli/commands/tags/apply.md `l4 tags apply` makes a virtual key in LevelFour match a YAML file. When no virtual key has the file's `name`, it creates one. When one does, the file replaces that key's whole definition. The command prints the difference before it sends anything. ```bash l4 tags apply -f [flags] ``` > **Note** > > Nothing changes in your cloud account. A virtual key changes how LevelFour labels your costs, and your provider tags stay as they are. ## What it changes The command reads `GET /api/v1/tags/keys` to find a virtual key named like the file, then: * with no such key, posts the file to `/api/v1/tags/virtual` and creates one * with one whose rules the file changes, sends `PUT /api/v1/tags/virtual/{key_id}` with the whole file, and the key's `rules_version` as `expected_version` * with one whose rules the file leaves alone, sends `PATCH /api/v1/tags/virtual/{key_id}` with the settings that moved, so editing a description does not reprocess a year of spend The file replaces the definition, so a config you removed from the file is gone from the key once the command finishes. A change to the configs, the collapsed keys, `can_override` or `effective_from` starts a job that reprocesses the key from `effective_from` to the current month, and the key reads `reprocessing` until it ends. > **Note** > > `apply` finds the key by name. Change `name` in the file and the next run creates a second key, leaving the first in place. To rename a key, send its new name to `PATCH /api/v1/tags/virtual/{key_id}`, then change the file to match. ## File format A tag file describes one virtual key. [`l4 tags preview`](/cli/commands/tags/preview) reads the same format. ```yaml title="teams.yaml" name: Teams description: Owning team across AWS and GCP. effective_from: "2026-06" # optional, YYYY-MM; omitted = current month can_override: false # optional collapsed_keys: # optional, in priority order - source: {origin: provider, key: owner} providers: [aws] # optional scope prefix: "" # optional value prefix configs: # in priority order, first match wins - value: data rules: - providers: [aws, gcp] where: - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]} - value: team-a rules: - providers: [aws, gcp] where: - {dimension: tag, key: team, operator: flexible_match, values: [Team A]} - split: - {value: infra, pct: 60} - {value: mobile, pct: 40} rules: - providers: [aws] where: - {dimension: account, operator: is, values: ["999900001111"]} - cost_based: source: {origin: provider, key: team} input: - providers: [aws] where: - {dimension: charge_type, operator: is, values: [Usage]} rules: - providers: [aws] where: - {dimension: service, operator: starts_with, values: [AWS Support]} time_frame: {start: "2026-07-01"} # optional ``` | Field | Meaning | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `name` | Required. The key's name, and how `apply` finds the saved key | | `description` | Free text | | `effective_from` | First billing month the key covers, as a quoted `YYYY-MM`. Omitted means the current month | | `can_override` | On a key named after a provider key, whether configs replace the provider value. Defaults to `false` | | `collapsed_keys[].source` | The key to copy from: `origin` (`provider` or `virtual`) and `key` | | `collapsed_keys[].providers` | Limits the source to these providers | | `collapsed_keys[].prefix` | Text put in front of the copied value | | `configs[]` | Required. Each config carries one of `value`, `split` or `cost_based`, and only one, plus `rules` and an optional `time_frame` | | `configs[].value` | One value for every line the config matches | | `configs[].split[]` | `value` and `pct` pairs that add up to 100 | | `configs[].cost_based` | `source`, the key whose values share the cost, and `input`, rules that select the cost to measure the shares on | | `configs[].rules[]` | `providers` (`aws`, `gcp`) and `where`. One matching rule is enough | | `configs[].rules[].where[]` | Conditions, all of which must hold: `dimension`, `key`, `operator` (default `is`) and `values` | | `configs[].time_frame` | `start` and `end` dates as `YYYY-MM-DD`. Either one can be left out | `l4` rejects a field it does not recognise, so a typo such as `condition:` for `where:` fails the command instead of dropping the rule. [Virtual tags](/guides/virtual-tags) has the dimensions, the operators, and a worked example for each kind of config. ## Flags | Flag | Description | | ----------- | ----------------------------------------------------------------------------------------- | | `-f ` | Tag file to apply. Required | | `--dry-run` | Print the difference and send nothing. Exits `2` when the file differs from the saved key | | `--yes` | Skip the confirmation prompt | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## Dry run `--dry-run` reads the saved key, prints the difference and stops. It only reads, so a read-only key can run it. The exit code says what it found: | Exit | Meaning | | ---------- | ---------------------------------------------------------- | | `0` | The saved key matches the file | | `2` | Applying would change the key, or no key has that name yet | | `1` or `4` | An error, as in [Errors](#errors) | Run it on each pull request that touches a tag file, and the pipeline fails whenever the saved key and the file disagree: ```bash l4 tags apply -f tags/teams.yaml --dry-run --quiet ``` > **Success** > > A dry run that exits `2` is the check doing its job: it found a difference to apply. Exit `1` is the one that means something broke. ## Confirmation By default, the command prints the difference, then asks for confirmation with a `[y/N]` prompt. Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without sending the change. Pass `--yes` to skip the prompt in an interactive shell. When the file matches the saved key, there is nothing to confirm and nothing is sent. > **Warning** > > When stdout is not a TTY, so in a pipeline or a CI job, the prompt never appears and the change goes through. A job that runs `apply` without `--dry-run` rewrites the key and reprocesses its costs from `effective_from` with no one asked to confirm. ## Requires a read-write key Writes need the `org:tags:write` permission, which a read-write key carries, and `l4 auth login` mints a read-only key. A read-only key fails with exit `1` and `permission denied: this API key lacks permission for this operation`. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create a read-write key. ## Re-running it A second run with the same file finds no difference and sends nothing. The file wins over the dashboard: a run after someone edited the key there replaces their edit, and the printed difference shows what it undoes. If someone changes the key between the command's read and its write, the API answers `409` `version_conflict` and saves nothing. Run `apply` again to diff against the new version. ## Examples ```bash l4 tags apply -f teams.yaml l4 tags apply -f teams.yaml --dry-run l4 tags apply -f teams.yaml --yes --json LEVELFOUR_TOKEN=$L4_RW_KEY l4 tags apply -f teams.yaml --yes --quiet ``` Apply several files in order, with the key that another one reads going first: ```bash for f in tags/applications.yaml tags/business-units.yaml; do l4 tags apply -f "$f" --yes || exit 1 done ``` ## Output Default output is the difference, then a success line naming the key and the reprocessing job. With `--json` the whole API envelope is printed. The fields it carries: ```text .success .data.key # the key as l4 tags show --json prints it .data.key.id .data.key.rules_version .data.key.status # "reprocessing" while the job runs .data.job # null when nothing needs reprocessing .data.job.id .data.job.status # "queued" or "running" right after a change .data.job.from_period .data.job.to_period .timestamp ``` > **Success** > > `.data.job.status` at `queued` or `running` is the expected result after a rules change: the definition is saved and LevelFour is reprocessing costs. Follow the job with [`l4 tags show`](/cli/commands/tags/show#reprocessing-jobs). A run that only changed settings sends `PATCH` and answers with `.data.job` at `null`, which is also correct. `--dry-run --json` prints the plan instead of an API envelope: `action` (`create`, `update` or `none`), `key_id` on an existing key, and `definition`, the body the real run would send. > **Warning** > > `apply` asks for confirmation the same way `delete` does. Outside a terminal, without `--yes`, it exits `1` with `applying <key> outside a terminal needs --yes` and sends nothing. A pipeline that means to apply passes `--yes`; one that means to check passes `--dry-run`. ## Validation problems A file that breaks a rule fails with `422` `validation_failed`, and the error lists every problem at once. Each problem carries a `code` and, where one applies, the position of the config, rule, condition, split or collapsed key at fault. The API counts those from `0` and the CLI prints them from `1`, matching how [`l4 tags show`](/cli/commands/tags/show) numbers configs. The codes a file author meets most: | Code | Cause | | ------------------------------------ | -------------------------------------------------------------------------------------- | | `rule_no_provider` | A rule lists no provider | | `rule_no_condition_value` | A condition has no `values` | | `key_required` | A `tag` or `virtual_tag` condition has no `key` | | `dimension_unsupported_for_provider` | The dimension does not exist on one of the rule's providers, such as `category` on GCP | | `operator_unsupported` | The operator does not apply to the dimension | | `split_not_100` | A split's percentages do not add up to 100 | | `split_value_duplicate` | A value appears twice in one split | | `time_frame_invalid` | The time frame ends before it starts | | `effective_from_invalid` | `effective_from` is not a `YYYY-MM` month | | `effective_from_unavailable` | `effective_from` is further back than the backfill limit | | `collapsed_source_unknown` | A collapsed key names a key that does not exist | | `cost_source_unknown` | A cost-based `source` names a key that does not exist | | `nested_key_unknown` | A `virtual_tag` condition names a key that does not exist | | `nested_cycle` | A chain of keys that read each other loops back to its start | | `nesting_too_deep` | A chain of nested keys goes past the depth limit | | `nested_allocation_unsupported` | A `virtual_tag` condition reads a key that holds split or cost-based configs | | `limit_exceeded` | The key goes past one of the [limits](/guides/virtual-tags#limits) | The [Tags API reference](/api-reference/tags) lists every code. ## Errors | Exit | Symptom | Cause | | ---- | ------------------------------------------------ | ----------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `2` | `--dry-run` found a difference | The file and the saved key differ. Nothing was sent | | `1` | The file does not parse, or has an unknown field | Fix the file against [File format](#file-format) | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `403` permission denied | The key is read-only, or your role lacks `org:tags:write` | | `1` | `409` `version_conflict` | The key changed between the read and the write. Run `apply` again | | `1` | `422` `validation_failed` | The file breaks a rule. See [Validation problems](#validation-problems) | ## Related * [`l4 tags preview`](/cli/commands/tags/preview): try the file before you apply it * [`l4 tags show`](/cli/commands/tags/show): the saved key and its reprocessing job * [`l4 tags delete`](/cli/commands/tags/delete): remove a key * [Virtual tags](/guides/virtual-tags): worked examples for every kind of config * [CLI authentication](/cli/authentication): read versus read-write keys --- # l4 tags delete Source: https://docs.levelfour.ai/cli/commands/tags/delete.md `l4 tags delete` deletes a virtual key, along with every value it assigned to your costs. It cannot delete a provider key: those come from your bill. ```bash l4 tags delete [--yes] ``` `` is an ID or a name, as [Naming a key](/cli/commands/tags#naming-a-key) describes. ## What it changes The command sends `DELETE /api/v1/tags/virtual/{key_id}`. Once it succeeds: * the key and its values are gone from every month * breakdowns, filters and recommendations stop offering the key * your provider tags stay as they are The API refuses with `409` `has_dependents` while another key reads this one, through a `virtual_tag` condition, a collapsed key or a cost-based source. The error lists those keys, and [`l4 tags show`](/cli/commands/tags/show) prints them under `dependents`. Change or delete them first. > **Warning** > > Applying the same file again rebuilds the key, but only as far back as the backfill limit reaches. Months older than that stay without a value. [Limits](/guides/virtual-tags#limits) has the limit. ## Flags | Flag | Description | | ------- | ---------------------------- | | `--yes` | Skip the confirmation prompt | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## Confirmation By default the command names the key and asks for confirmation with a `[y/N]` prompt. Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. Pass `--yes` to skip the prompt. > **Warning** > > Outside a terminal there is nobody to answer the prompt, so a piped or scheduled run without `--yes` exits `1` with `deleting ... outside a terminal needs --yes` and deletes nothing. It exits before anything is sent. Re-applying the file afterwards rebuilds the key only as far back as the backfill window reaches. ## Requires a read-write key Deleting needs the `org:tags:write` permission, which a read-write key carries, and `l4 auth login` mints a read-only key. A read-only key fails with exit `1` and `permission denied: this API key lacks permission for this operation`. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create a read-write key. ## Re-running it The reference goes to the route as given, and the route resolves a virtual key by name, so a second delete answers `404` `tag_not_found` whether it named the key or its ID. Scripts that clean up keys can treat that as done. ## Examples ```bash l4 tags delete Teams l4 tags delete vtk_3f9a1c --yes LEVELFOUR_TOKEN=$L4_RW_KEY l4 tags delete Teams --yes --quiet ``` ## Output Default output is a success line naming the key. The API answers `204` with no body, so script against the exit code: `0` means the key is gone. With `--json` the command prints `{"key": "", "deleted": true}`. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------------------------- | ---------------------------------------------------------------------- | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `1` | `401` authentication failed | Token revoked, expired, or from the wrong environment | | `1` | `403` permission denied | The key is read-only, or your role lacks `org:tags:write` | | `1` | `no virtual tag key named ...` | A name nobody holds, resolved locally against the key list | | `1` | `... is a provider tag key` | A `ptk_` ID or a provider key's name. Provider tags come from the bill | | `1` | `404` `tag_not_found` | A `vtk_` ID nobody holds | | `1` | `deleting ... outside a terminal needs --yes` | No TTY to confirm on, and no `--yes` | | `1` | `409` `has_dependents` | Another key reads this one. The error names it | ## Related * [`l4 tags show`](/cli/commands/tags/show): check `dependents` before you delete * [`l4 tags apply`](/cli/commands/tags/apply): recreate a key from its file * [CLI authentication](/cli/authentication): read versus read-write keys --- # Overview Source: https://docs.levelfour.ai/cli/commands/alerts.md `l4 alerts` manages your organization's [cost alerts](/cost-alerts): the rules that watch an account, a saved cost view or your commitments, and tell the people and Slack channels named on them when a line is crossed. `costalerts` and `alert` are aliases. ```bash l4 alerts [flags] ``` ## Subcommands | Command | Purpose | Scope | | --------------------------------------- | ------------------------------------------------------ | ------------ | | [`list`](#list) | Every alert, what it watches, and whether it is firing | `read` | | [`get`](#get) | One alert in full: recipients, Slack channels and rule | `read` | | [`sources`](#sources) | The ids a rule's `source_id` can take | `read` | | [`events`](#events) | What an alert has said, newest first | `read` | | [`create`](/cli/commands/alerts/create) | Create an alert from a JSON rule file | `read-write` | | [`update`](/cli/commands/alerts/update) | Change the fields a JSON file names | `read-write` | | [`pause`, `resume`](#pause-and-resume) | Silence an alert, or arm it again | `read-write` | | [`delete`](/cli/commands/alerts/delete) | Delete an alert and its history | `read-write` | Every command that takes an `` checks it before calling the API. An id is a UUID, as `l4 alerts list` prints it, and anything else fails with `... is not a cost alert id`. Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply to every subcommand. See [CLI overview](/cli#global-flags). ## list ```bash l4 alerts list ``` One row per alert: its id, name, source, the kind of rule it is (`budget`, `changes` or `commitment`), and its status. `firing` means at least one thing the alert watches has crossed its line, and outranks `enabled`. A paused alert reads `paused by` the person or key that paused it. ## get ```bash l4 alerts get ``` The alert's name, id, source, kind, status, recipients and Slack channels, then the rule as compact JSON. A channel prints as `#name (id)`, with the name it had when the alert was saved. `--json` prints the whole alert as the API returns it, which is also the shape a rule file takes. ## sources ```bash l4 alerts sources ``` What an alert can watch, as `ID`, `Label`, `Group` and `Provider`. `Group` is `accounts`, `views` or `commitments`. Every id here is valid in the `source_id` field of a rule file. ## events ```bash l4 alerts events [--limit 50] [--offset 0] ``` One row per firing and per recovery, newest first: when, the slice it was about (`whole source` when the alert watches one thing), the state, the figure observed, the threshold, the cost basis, and whether anyone was notified. | Flag | Description | | ---------- | --------------------------------------- | | `--limit` | Events per page, 1 to 200. Default `50` | | `--offset` | Events to skip. Default `0` | ## Pause and resume ```bash l4 alerts pause [--yes] l4 alerts resume [--yes] ``` `pause` stops an alert from notifying anyone and keeps it, with its history. `resume` arms it again. Both prompt for confirmation unless you pass `--yes`, print the alert's new status, and need a read-write key. ## Rule files `create` and `update` read JSON from a file, or from stdin with `--file -`, so a rule can be reviewed in a pull request before it reaches the API. The file is the request body: [`create`](/cli/commands/alerts/create) has the fields, and [Cost alerts](/cost-alerts#types) explains each kind of rule. The CLI refuses a file over 1 MiB, or one that is not a JSON object, before it sends anything. ## Examples ```bash l4 alerts list l4 alerts get 8f2b1c4e-0000-4a1b-9c3d-5e6f70819234 --json l4 alerts create --file production-budget.json l4 alerts pause 8f2b1c4e-0000-4a1b-9c3d-5e6f70819234 --yes ``` ## Related * [Cost alerts](/cost-alerts): the types of rule, where alerts arrive, and how often they are checked * [Cost Alerts API reference](/api-reference/cost-alerts): the endpoints underneath * [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key): the read-write key the writes need --- # l4 alerts create Source: https://docs.levelfour.ai/cli/commands/alerts/create.md `l4 alerts create` creates a cost alert from a JSON rule file. ```bash l4 alerts create --file [--yes] ``` ## The rule file ```json title="production-budget.json" { "name": "Production budget", "source_id": "123456789012", "params": { "kind": "budget", "budget": 40000, "reset": "monthly", "rules": [{ "amount": 80, "unit": "percent" }] }, "recipients": [{ "name": "Ana", "email": "ana@example.com", "member": true }], "slack_channel_ids": ["C0123ABCD"] } ``` | Field | Description | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | What the alert is called, shown in every notification | | `source_id` | What it watches: an id from [`l4 alerts sources`](/cli/commands/alerts#sources) | | `params` | The rule. `kind` is `budget`, `changes` or `commitment`, and [Cost alerts](/cost-alerts#types) says when each one fires | | `recipients` | Members of your organization to notify. Each takes `name`, `email` and `member: true` | | `slack_channel_ids` | Up to 10 Slack channel ids to post in. Leave it out to post in your organization's default channel, if one is set. An empty list posts in no channel | An alert needs a recipient or a Slack channel, and the API refuses one with neither. ## Flags | Flag | Description | | -------------- | ----------------------------------------------------- | | `-f`, `--file` | The rule file, or `-` to read it from stdin. Required | | `-y`, `--yes` | Skip the confirmation prompt | ## Confirmation At a terminal, the command asks `Create this cost alert? [y/N]`. Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. In a pipe or a script, where stdout is not a terminal, it creates the alert without asking. > **Warning** > > `--file -` reads the rule from stdin, which leaves nothing to answer the prompt with. At a terminal, pass `--yes` with it, or the command exits `1` with `--file - reads the rule from stdin, which leaves nothing to answer the prompt with: pass --yes`. ## Requires a read-write key `l4 auth login` mints a read-only key, which fails here with exit `1` and `permission denied`. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create a read-write key. ## Output A success line, then the new alert's id, name and source. With `--json`, the alert as the API stored it. ## Errors | Exit | Symptom | Cause | | ---- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `1` | `--file is required` | No `--file` was passed | | `1` | `cannot read ...` or `... is not a JSON object` | The file is missing, or is not one JSON object | | `1` | `... is larger than 1048576 bytes` | The file is over 1 MiB | | `1` | `403` permission denied | The key is read-only | | `1` | `422` with a reason | The API refused the rule: a source that does not fit the kind, an alert that reaches nobody, or a Slack channel the LevelFour bot cannot see | ## Related * [`l4 alerts update`](/cli/commands/alerts/update): change an alert from a file of the fields to change * [`l4 alerts get`](/cli/commands/alerts#get): read a rule back as JSON --- # l4 alerts update Source: https://docs.levelfour.ai/cli/commands/alerts/update.md `l4 alerts update` changes a cost alert. It sends only the fields its file names, and leaves every other field as it is. ```bash l4 alerts update --file [--yes] ``` ## The change file The file takes any of `name`, `params`, `source_id`, `recipients` and `slack_channel_ids`, in the shapes [`l4 alerts create`](/cli/commands/alerts/create#the-rule-file) describes. * `params` replaces the whole rule. To move one threshold, send the rule back with that figure changed. * `recipients` and `slack_channel_ids` replace the list they name. An empty `slack_channel_ids` takes every channel off. * The CLI refuses a file that names no field with `... changes nothing`, before it sends anything. Raise a monthly budget to 50,000, keeping the rest of the rule: ```bash l4 alerts get --json \ | jq '{params: (.data.params | .budget = 50000)}' \ | l4 alerts update --file - --yes ``` To pause an alert, use [`l4 alerts pause`](/cli/commands/alerts#pause-and-resume). ## Flags | Flag | Description | | -------------- | ------------------------------------------------------- | | `-f`, `--file` | The change file, or `-` to read it from stdin. Required | | `-y`, `--yes` | Skip the confirmation prompt | ## Confirmation At a terminal, the command asks `Change cost alert ? [y/N]`. Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. In a pipe or a script, where stdout is not a terminal, it changes the alert without asking. With `--file -` at a terminal, pass `--yes`, for the reason [`create`](/cli/commands/alerts/create#confirmation) gives. ## Requires a read-write key A read-only key fails with exit `1` and `permission denied`. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key). ## Output A success line, then the alert as it now reads, in the layout of [`l4 alerts get`](/cli/commands/alerts#get). With `--json`, the alert as the API returns it. ## Errors | Exit | Symptom | Cause | | ---- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `1` | `... is not a cost alert id` | The id is not a UUID | | `1` | `... changes nothing` | The file is an empty object | | `1` | `403` permission denied | The key is read-only | | `1` | `404` | No alert has that id in your organization | | `1` | `422` with a reason | The API refused the change for a reason [`create`](/cli/commands/alerts/create#errors) lists, or for a field set to `null` | --- # l4 alerts delete Source: https://docs.levelfour.ai/cli/commands/alerts/delete.md `l4 alerts delete` deletes a cost alert, along with every firing and recovery it recorded. Nothing brings either back. To stop an alert's notifications and keep its history, [pause it](/cli/commands/alerts#pause-and-resume) instead. ```bash l4 alerts delete [--yes] ``` ## Flags | Flag | Description | | ------------- | ---------------------------- | | `-y`, `--yes` | Skip the confirmation prompt | ## Confirmation At a terminal, the command asks `Delete cost alert and its history? [y/N]`. Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without calling the API. > **Warning** > > Outside a terminal there is nobody to answer the prompt, so a piped or scheduled run without `--yes` exits `1` with `deleting outside a terminal needs --yes`, before the CLI sends anything. ## Requires a read-write key A read-only key fails with exit `1` and `permission denied`. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key). ## Output A success line naming the alert. With `--json`, `{"id": "", "deleted": true}`. ## Errors | Exit | Symptom | Cause | | ---- | --------------------------------------------- | ----------------------------------------------- | | `1` | `... is not a cost alert id` | The id is not a UUID | | `1` | `deleting ... outside a terminal needs --yes` | No terminal to confirm on, and no `--yes` | | `1` | `403` permission denied | The key is read-only | | `1` | `404` | No alert has that id, or it was already deleted | --- # l4 mcp Source: https://docs.levelfour.ai/cli/commands/mcp.md `l4 mcp` wires the agent clients on this machine to your cloud spend, so you can ask about it in the client you already use. It writes the client configuration for you rather than leaving you to hand-edit JSON. > **Note** > > [`l4 mcp install`](/cli/commands/mcp/install) connects clients with a LevelFour API key. Signing in through the browser is the other credential, and it writes nothing to disk. [Connecting a client](/mcp/authentication) compares them. ## Subcommands | Command | What it does | Reach for it when | | ------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | [`l4 mcp install`](/cli/commands/mcp/install) | Writes the server into every client it can confirm on this machine | You want every client wired in one step | | [`l4 mcp status`](/cli/commands/mcp/status) | Reports, per client, whether it is installed and whether it carries a LevelFour entry | You have just installed, or an agent reports no LevelFour tools | | [`l4 mcp uninstall`](/cli/commands/mcp/uninstall) | Removes the entry, and can delete the dated backups that still hold a previous key | You are taking it back out, or a backup still holds an old key | | [`l4 mcp serve`](/cli/commands/mcp/serve) | Relays the hosted server over stdio on your own machine | Rarely by hand. `install` points Claude Desktop at it | ## Clients it configures `install`, `status` and `uninstall` know Claude Code, Claude Desktop, Cursor, VS Code and Windsurf. Any other client that speaks MCP over streamable HTTP reaches the same server, [configured by hand](/mcp/authentication). ## Related * [MCP server](/mcp): the hosted surface, its tools and its prompts * [Connecting a client](/mcp/authentication): signing in, keys, and per-client configuration * [Approving a connection](/mcp/approving-a-connection): what the browser asks when you sign in * [Tools](/mcp/tools): every tool the server registers * [CLI overview](/cli): installing `l4`, the global flags, and the exit codes --- # l4 mcp install Source: https://docs.levelfour.ai/cli/commands/mcp/install.md `l4 mcp install` writes the LevelFour MCP server into the configuration of the agent clients it finds on this machine, using a LevelFour API key. ```bash l4 mcp install [flags] ``` With no `--client`, it configures every client it can confirm. With no `--token`, it uses the credential `l4` already holds, and opens the browser to mint a `read` key when there is none. > **Note** > > Restart the client after an install, or it will not pick up the new entry. A key is one of two ways in. A client pointed at `https://mcp.levelfour.ai/mcp` with no header sends you to the browser to sign in instead. Reach for `install` when you want one command to configure every client on the machine, or when the client runs unattended. [Connecting a client](/mcp/authentication) compares them. ## Flags | Flag | Description | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `--client` | Client to configure: `claude-code`, `claude-desktop`, `cursor`, `vscode`, `windsurf`. Repeatable. Defaults to every client detected | | `--name` | Name for the server entry. Defaults to `levelfour` | | `--endpoint` | MCP endpoint to point every client at, Claude Desktop included. Defaults to the hosted server | | `--key-source` | `inline` (default) writes the credential into the config. `env` writes a reference to the `LEVELFOUR_TOKEN` environment variable instead | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## Which clients it touches The command distinguishes proof that a client is installed from a hint of it. An executable on `PATH`, an application bundle, or an existing MCP configuration file counts as proof. A directory the client created once is only a hint, because those outlive an uninstall, and writing a credential into a fresh configuration for software that is no longer there leaks it. It names the clients it only has a hint of, and skips them: ```text Detected Claude Code, Cursor. Configuring all of them; use --client to narrow. Skipping VS Code: configuration was found but the application was not. Add --client to configure it anyway. ``` ## What it writes Each vendor expects the same facts in a different file under a different key, so the shape differs per client. | Client | Written to | Transport | | -------------- | ------------------------------------- | -------------------------------------------------------------- | | Claude Code | `~/.claude.json` | Remote HTTP | | Claude Desktop | `claude_desktop_config.json` | Local stdio, running [`l4 mcp serve`](/cli/commands/mcp/serve) | | Cursor | `~/.cursor/mcp.json` | Remote HTTP | | VS Code | user-profile `mcp.json` | Remote HTTP | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | Remote HTTP | `install` parses the existing configuration and merges into it. It touches only the entry under `--name`, leaves the rest of the file as it was, and takes a dated `.l4-backup-` copy first. A file that is not valid JSON stops the install for that client; `install` never overwrites it. `~/.claude.json` also holds Claude Code's per-project state. `install` keeps every other key in it and replaces the file in one step rather than rewriting it in place, so the server loads in every project and nothing else changes. ## Where the credential goes > **Warning** > > By default `install` writes the key into the client's configuration file in plain text. The CLI writes files it creates itself `0600` on macOS and Linux. `--key-source env` writes a reference instead, so no key lands on disk. The spelling differs per vendor: | Client | Reference written | | --------------------- | ------------------------------------------------------------- | | Claude Code, Windsurf | `${LEVELFOUR_TOKEN}` | | Cursor | `${env:LEVELFOUR_TOKEN}` | | VS Code | an `inputs` prompt, stored in the editor's own secret storage | Export `LEVELFOUR_TOKEN` in the environment the client starts from, except for VS Code, which prompts once and keeps the value itself. `LEVELFOUR_TOKEN` is the variable `l4` itself reads, so one export serves the CLI and every client. Claude Desktop never receives a key either way. It runs [`l4 mcp serve`](/cli/commands/mcp/serve), which reads your keychain and relays the hosted server. ## More than one entry An API key belongs to exactly one organization, and to one scope. `--name` is how you keep several entries side by side, because each install only touches the entry under that name. Two organizations, one client: ```bash l4 mcp install --client cursor --token $ORG_A_KEY l4 mcp install --client cursor --name levelfour-org-b --token $ORG_B_KEY ``` A read-write key alongside the default read-only one, which the tools that record a decision need ([Key scopes](/authentication#key-scopes)): ```bash l4 mcp install --client cursor --name levelfour-rw --token $READ_WRITE_KEY ``` A second entry pointed somewhere other than production: ```bash l4 mcp install --client cursor --name levelfour-staging --endpoint https://mcp.example.internal/mcp ``` ## Examples Configure everything it can confirm: ```bash l4 mcp install ``` One client: ```bash l4 mcp install --client cursor ``` Several at once, without writing the key to disk: ```bash l4 mcp install --client cursor --client vscode --client windsurf --key-source env ``` Machine-readable, for a provisioning script: ```bash l4 mcp install --client cursor --json ``` > **Success** > > [`l4 mcp status`](/cli/commands/mcp/status) reads the entries back. **Configured** turns to `yes`, and the **Endpoint** column shows what that entry now points at. That is the proof, not the absence of an error message. ## Errors | Exit | Symptom | Cause | | ---- | ----------------------------------------- | ------------------------------------------------------------------------- | | `1` | `no MCP client detected on this machine` | Nothing confirmed. Install a client, or name one with `--client` | | `1` | `no client was configured` | Every client attempted failed. The message names each failure | | `1` | `N of M clients failed` | Some succeeded, some did not. The successful ones are configured | | `1` | `unknown client` | `--client` was given a name outside the supported set | | `1` | `is not valid JSON, so it was left alone` | The client's configuration file is malformed. Fix or move it, then re-run | A partial failure exits non-zero in both human and `--json` mode, because a client that failed has no LevelFour tools. ## Related * [`l4 mcp status`](/cli/commands/mcp/status): what is wired up, and what is not * [`l4 mcp uninstall`](/cli/commands/mcp/uninstall): remove the entry, and the backups holding the old key * [`l4 mcp serve`](/cli/commands/mcp/serve): the local server this points Claude Desktop at * [Connecting a client](/mcp/authentication): the configuration each client expects, by hand --- # l4 mcp uninstall Source: https://docs.levelfour.ai/cli/commands/mcp/uninstall.md `l4 mcp uninstall` removes the entry [`l4 mcp install`](/cli/commands/mcp/install) wrote from every client that carries it. ```bash l4 mcp uninstall [flags] ``` With no `--client`, it cleans every client carrying an entry under `--name`, and leaves alone any client that is installed but carries none. On a terminal it asks you to confirm first. Declining prints `Aborted.` and exits `0`. In CI or through a pipe stdin is not a terminal, so it proceeds without asking. Pass `--yes` there to make that explicit in the script. ## Flags | Flag | Description | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `--client` | Client to clean: `claude-code`, `claude-desktop`, `cursor`, `vscode`, `windsurf`. Repeatable. Defaults to every client carrying the entry | | `--name` | Name of the server entry to remove. Defaults to `levelfour` | | `--purge-backups` | Also delete the dated `.l4-backup-*` copies | | `-y`, `--yes` | Skip the confirmation prompt | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## What it touches Only the entry under `--name`. It leaves every other MCP server in the same file exactly as it was, and takes a dated backup before rewriting anything. An install made under a different name survives. If you installed a second organization as `levelfour-org-b`, remove it by name: ```bash l4 mcp uninstall --name levelfour-org-b ``` ## The backups it leaves behind `l4 mcp install` backs up a config file before rewriting it. > **Warning** > > When you install over an existing entry, the backup it takes holds the previous credential in plain text, and removing the entry does not remove that copy. By default the backups stay and the command tells you how many are left: ```text Cursor: removed entry "levelfour" Backup: /Users/you/.cursor/mcp.json.l4-backup-20260829-141207 2 backup file(s) left in place. A backup taken over an existing install still holds that credential; --purge-backups deletes them. ``` To delete them as it removes the entry: ```bash l4 mcp uninstall --purge-backups ``` > **Note** > > If you connected by signing in rather than with a key, no credential was ever written to disk and the backups hold nothing sensitive. Removing the client entry stops the client asking; it does not revoke what it already holds. Close the connection under [Connected Applications](/mcp/approving-a-connection#seeing-and-closing-connections) in the dashboard. ## Examples Remove it everywhere: ```bash l4 mcp uninstall ``` One client: ```bash l4 mcp uninstall --client cursor ``` Unattended, deleting the backups as it goes: ```bash l4 mcp uninstall --yes --purge-backups ``` ## Errors | Exit | Symptom | Cause | | ---- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `1` | `no client carries an entry named "levelfour". Nothing to remove` | Nothing to do. Check the name with [`l4 mcp status`](/cli/commands/mcp/status) | | `1` | `N of M clients failed` | It cleaned some and not others. The message names each failure | | `1` | `unknown client` | `--client` was given a name outside the supported set | ## Related * [`l4 mcp install`](/cli/commands/mcp/install): what this reverses * [`l4 mcp status`](/cli/commands/mcp/status): which clients carry an entry * [Approving a connection](/mcp/approving-a-connection): closing a connection you signed in for --- # l4 mcp serve Source: https://docs.levelfour.ai/cli/commands/mcp/serve.md `l4 mcp serve` speaks MCP over stdin and stdout and relays every request to the hosted server at `https://mcp.levelfour.ai/mcp`, with the credential already in your keychain. ```bash l4 mcp serve [flags] ``` You rarely run this yourself. [`l4 mcp install`](/cli/commands/mcp/install) points Claude Desktop at it, because `claude_desktop_config.json` starts stdio servers and cannot send an `Authorization` header to a remote one. Run by hand it waits for a client that will never speak. ## Flags | Flag | Description | | ------------ | ---------------------------------------------------- | | `--endpoint` | MCP endpoint to relay. Defaults to the hosted server | Global flags (`--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## What it serves The same [tools](/mcp/tools) the hosted server offers your key, under the same names and schemas, plus its prompts and resources. An agent that learned to route against the hosted server gets the same answers here. The binary carries no copy of the tools, so a tool the hosted server adds appears here without upgrading `l4`. [`l4 auth login`](/cli/authentication#browser-login-mints-a-read-only-key) mints a `read` key, which gets the read tools. A `read-write` key also gets the tools that write. To accept or reject from the terminal instead, use [`l4 rec accept`](/cli/commands/recommendations/accept) and [`l4 rec reject`](/cli/commands/recommendations/reject). It needs network access to the hosted server and MCP access enabled for your organization. ## Why your key stays put `l4 mcp install` writes no key into the client's configuration file. The server sends the credential [`l4` already resolves](/cli/authentication) to the hosted server and nowhere else: it refuses a redirect to another host, or to plain HTTP, rather than carry the key along. This is the one client path that keeps the key out of every config file. ## Output The protocol occupies stdout, so every notice goes to stderr, which your client captures into its own log. Claude Desktop writes `mcp-server-levelfour.log`. The server prints two lines at startup: ```text levelfour mcp 0.2.0 serving 36 tools, 5 prompts, 4 resources over stdio relaying https://mcp.levelfour.ai/mcp with the stored credential ``` > **Success** > > Those two lines are what a healthy start looks like. They name which binary answered, what the hosted server offered your key, and where it came from. The server reads the counts from the hosted server at startup, so yours may differ. ## Errors The server checks the hosted server before it starts serving, and gives up after 15 seconds without an answer. If that check fails, it exits with the reason on stderr, so the client logs a cause instead of showing a server with no tools. | Exit | Symptom | Cause | | ---- | -------------------------------------------------------------- | ------------------------------------------------------------------ | | `4` | Authentication required | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN` | | `4` | Refused this credential, and so did the LevelFour API | The key was revoked or has expired; run `l4 auth login` | | `1` | Refused this credential, although the LevelFour API accepts it | MCP access is not enabled for your organization; contact LevelFour | | `1` | Connecting to the LevelFour MCP server | No network path to the endpoint | Once it is serving, a tool call that cannot reach a tool returns an error result naming it, so the agent can tell you why the answer is missing. If someone revokes the key while the client is open, the next call fails with that cause, and [`l4 mcp status`](/cli/commands/mcp/status) says whether the key or your organization's MCP access is the reason. A client disconnecting is a normal ending and exits `0`. Returning an error there would make every ordinary exit non-zero, which clients log as a crashed server. Every other code follows the [standard CLI exit conventions](/cli#exit-codes). ## Related * [`l4 mcp install`](/cli/commands/mcp/install): points Claude Desktop at this command * [`l4 mcp status`](/cli/commands/mcp/status): check what the hosted server offers the credential this would use * [Tools](/mcp/tools): every tool the hosted server registers --- # l4 mcp status Source: https://docs.levelfour.ai/cli/commands/mcp/status.md `l4 mcp status` reports, per client, whether it is installed on this machine and whether it carries a LevelFour entry, and what the hosted server offers your credential. Run this first when an agent says it cannot see any LevelFour tools. ```bash l4 mcp status [flags] ``` ## Flags | Flag | Description | | ------------ | ------------------------------------------------------------- | | `--name` | Name of the server entry to look for. Defaults to `levelfour` | | `--endpoint` | MCP endpoint to check. Defaults to the hosted server | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). ## Output ```text LevelFour MCP Entry name: levelfour Endpoint: https://mcp.levelfour.ai/mcp Surface for this key: 36 tools, 5 prompts, 4 resources Credential: system keychain (l4_live_...abcd) ╭────────────────┬───────────┬────────────┬──────────────────────────────────╮ │ Client │ Installed │ Configured │ Endpoint │ ├────────────────┼───────────┼────────────┼──────────────────────────────────┤ │ Claude Code │ yes │ yes │ configured in Claude Code │ │ Claude Desktop │ yes │ yes │ /opt/homebrew/bin/l4 mcp serve │ │ Cursor │ yes │ no │ │ ╰────────────────┴───────────┴────────────┴──────────────────────────────────╯ ``` The block above the table is global rather than per client: the entry name being looked for, the endpoint, what that server offers the credential `l4` resolved for itself, and where that credential came from. `l4 mcp status` reads the surface line live from the server, so it matches what [`l4 mcp serve`](/cli/commands/mcp/serve) relays. | Column | What it means | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Installed** | The client was found on this machine | | **Configured** | It carries an entry under `--name` | | **Endpoint** | What that entry points at, read back from the file rather than the hosted default above. This is how you catch an entry left pointing somewhere unexpected | > **Success** > > A wired client reads `yes` in both columns, with an endpoint you recognize beside it. If a client is installed but not configured, run [`l4 mcp install --client `](/cli/commands/mcp/install). > **Warning** > > **Configured** is read under `--name`. A client wired under another entry name, `levelfour-org-b` for instance, reports `no` here until you pass the same `--name`. ## Reading the surface line A healthy line counts tools, prompts and resources. If `l4` cannot read the server, the line starts `unavailable:` and carries the reason [`l4 mcp serve`](/cli/commands/mcp/serve#errors) would stop on, and that page explains each one. With no credential at all, it reads `unknown until you authenticate`. ## Reading the credential line `Credential` names where the key came from: the `--token` flag, `LEVELFOUR_TOKEN`, or the system keychain. [CLI authentication](/cli/authentication) has the order `l4` resolves them in. > **Error** > > `Credential: none` means nothing resolved. Run `l4 auth login`. The key is masked, so the source is the part to read: it names where to go when the wrong credential is in play. Where a client keeps its own key is a separate question, and [`l4 mcp install`](/cli/commands/mcp/install) covers it. ## Related * [`l4 mcp install`](/cli/commands/mcp/install): configure a client that reports no under Configured * [`l4 mcp uninstall`](/cli/commands/mcp/uninstall): remove an entry it reports * [`l4 mcp serve`](/cli/commands/mcp/serve): relays the surface this reports on * [CLI authentication](/cli/authentication): how credentials resolve --- # l4 integrations Source: https://docs.levelfour.ai/cli/commands/integrations.md `l4 integrations` shows which cloud providers (AWS, GCP, Azure, Kubernetes) are connected to your organization. [Providers](/resources/providers) has the `provider_id` each one goes by. Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). Exit codes follow the [standard CLI exit conventions](/cli#exit-codes). ## list ```bash l4 integrations list ``` Returns one row per connected provider, its `provider_id` under **ID** and its `provider_name` under **Name**: ```text ╭───┬────╮ │ID │Name│ ├───┼────┤ │aws│AWS │ │gcp│GCP │ ╰───┴────╯ ``` > **Success** > > A provider listed here is connected, and its **ID** is the value `--provider` takes on [`l4 costs`](/cli/commands/costs) and [`l4 recommendations`](/cli/commands/recommendations). A provider you expect and do not see has not been [connected](/quickstart) yet. `--json` returns the same rows in a JSON envelope: ```bash l4 integrations list --json ``` ```json { "success": true, "data": [ { "provider_id": "aws", "provider_name": "AWS" }, { "provider_id": "gcp", "provider_name": "GCP" } ], "timestamp": "2026-04-27T11:00:00Z" } ``` ## Examples Read the `provider_id` values on their own, for scripting: ```bash l4 integrations list --jq '.data[] | .provider_id' ``` Fail a pipeline when a provider is not connected: ```bash l4 integrations list --jq '.data[] | select(.provider_id == "aws")' --quiet \ || { echo "AWS integration missing"; exit 1; } ``` ## Related * [`l4 costs`](/cli/commands/costs): where a `provider_id` becomes `--provider` * [`l4 recommendations`](/cli/commands/recommendations): the other command scoped by provider * [Providers](/resources/providers): the same list over the REST API and the SDKs * [Quickstart](/quickstart): how a provider gets connected in the first place * [Output formats](/cli/output-formats): what `--json`, `--jq` and `--quiet` each emit --- # l4 status Source: https://docs.levelfour.ai/cli/commands/status.md `l4 status` reports the API's health and your account's current evaluation state. Run it as a pre-flight check before heavier commands, or as a CI smoke test. ```bash l4 status ``` > **Success** > > A plain run prints a `Status` line and a `Base URL` line. Those two lines are the proof the API answered, and the base URL confirms which API you reached. Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). Exit codes follow the [standard CLI exit conventions](/cli#exit-codes). > **Note** > > `l4 status` resolves a credential before it calls anything, so a CI runner with no `LEVELFOUR_TOKEN` set fails on authentication rather than reporting on the API. [CLI authentication](/cli/authentication) has the resolution order and the troubleshooting table. ## Examples Pipe to JSON for monitoring scripts: ```bash l4 status --json ``` Reduce the run to an exit code, with nothing on stdout or stderr: ```bash l4 status --quiet ``` [Output formats](/cli/output-formats#--quiet) has what `--quiet` suppresses and how to read the result from `$?`. ## Related * [`l4 whoami`](/cli/authentication#inspect-the-active-session): identity and organization context * [`l4 mcp status`](/cli/commands/mcp/status): a different status (which agent clients carry a LevelFour entry) --- # l4 estimate Source: https://docs.levelfour.ai/cli/commands/estimate.md `l4 estimate` prices the Terraform and CloudFormation in your workspace and estimates the monthly cost of the resources they define. It needs no `terraform plan` and no deployed stack. Point it at directories, individual files, or a mix. It reads a directory recursively. ```bash l4 estimate l4 estimate ./infra/ l4 estimate main.tf variables.tf l4 estimate template.yaml --parameter Env=prod ``` Each Terraform root module is priced once, with the modules it calls. Each CloudFormation template is priced with the nested stack templates, `AWS::Include` snippets and `samconfig` files it uses. [CloudFormation](#cloudformation) covers what leaves your machine. ## Flags | Flag | Description | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--region ` | AWS region for pricing. Order: `--region`, then `region_override` in `.levelfour/config.yml`, then the template's `samconfig` (for Terraform, its provider block), then `us-east-1` | | `--gcp-region ` | GCP region (default: auto-detect or `us-central1`) | | `--azure-region ` | Azure region (default: auto-detect or `eastus`) | | `--var ` | Terraform variable (repeatable) | | `--var-file ` | Terraform variable file (repeatable) | | `--download-modules` | Download remote modules for accurate estimates (default `true`) | | `--parameter ` | CloudFormation parameter value, given to every root template that declares `Key` (repeatable). A nested stack gets the parameters its parent passes | | `--parameters-file ` | CloudFormation parameters file for the root templates, in the AWS CLI JSON format or the CodePipeline format | | `--config-env ` | The `samconfig` environment whose parameters and region apply (default `default`) | | `--out-file ` | Save the Terraform resource snapshot for later use with `l4 diff` | | `--fail-above ` | Exit code `2` if monthly cost exceeds this threshold | | `--format` | `table`, `json`, or `github-comment` (default `table`) | > **Warning** > > Pricing is regional. When no flag, config, provider block or `samconfig` names a region, every AWS resource is priced in `us-east-1`, which returns a plausible total that is wrong for any other region. Global flags apply, including `--quiet` and `--token`. See [CLI overview](/cli#global-flags). ## CloudFormation `l4 estimate` reads YAML, JSON and SAM templates. It tells a template apart from other YAML and JSON by its content, so a Kubernetes manifest or a `package.json` in the same directory is left alone. The CLI parses Terraform on your machine and sends only the resources to the API. **CloudFormation templates are uploaded to the LevelFour API**, which evaluates their parameters, conditions, intrinsic functions and nested stacks, then prices them. The upload holds: * the templates the CLI found * the nested stack templates and `AWS::Include` snippets they reference * the `samconfig` beside each template * the `--parameters-file` Nothing outside the git repository goes up. Outside a repository, nothing outside the directory you point at goes up. A file a template references is uploaded only when git tracks it and it is not hidden, vendored or a package manifest, so an ignored `credentials.json` or a `.env.json` stays on your machine. The CLI never follows a symlink to a file it uploads. Before the request goes out, it lists every file it uploads on stderr. A parameter of a root template takes the first value it finds: `--parameter`, then `--parameters-file`, then the `samconfig` environment `--config-env` names, then the template's `Default`. A nested stack takes the parameters its parent passes down, and its own `Default` for the rest. When the estimate rests on an assumption, such as a parameter priced at its default or a condition LevelFour could not settle, the output lists it under **Assumptions**. ### AWS CDK The CLI does not run `cdk synth`. Run it yourself, then name each synthesized template: ```bash cdk synth l4 estimate cdk.out/MyStack.template.json ``` A directory walk skips `cdk.out`, so name the templates as files. [Pull request bot](/integrations/pull-request-bot#how-it-prices-a-cloudformation-template) lists the rest of what LevelFour cannot price in a template. ## CI guardrail pattern Fail the build when the estimated monthly cost is above $500: ```bash l4 estimate --fail-above 500 -q ./infra/ ``` > **Success** > > A wired gate exits `2` once the estimate goes over the threshold. That is the guardrail firing rather than the command breaking, and the two carry different codes, so a job can branch on the difference. [Exit codes](/cli#exit-codes) has the full set. `--fail-above` gates on the total here, so a workspace that sits above the line fails every build, whether or not the change moved it. [`l4 diff`](/cli/commands/diff) gates on the delta instead. ## Saving a snapshot for later comparison Outside a git repo, save the current Terraform estimate as a baseline you can diff against later: ```bash l4 estimate --out-file baseline.json ./infra/ # ... make changes ... l4 diff baseline.json ./infra/ ``` In a git repo you need no snapshot at all. See [`l4 diff`](/cli/commands/diff) for the default baseline. ## Posting to a pull request The `github-comment` format is what the LevelFour GitHub Action uses. Post it by hand with: ```bash l4 estimate --format github-comment ./infra/ \ | gh pr comment "$PR_NUMBER" --body-file - ``` ## Related * [`l4 diff`](/cli/commands/diff) compares an estimate against a branch or a saved baseline * [CI/CD integration](/guides/ci-cd) has the GitHub Actions and GitLab CI jobs that run this command * [Output formats](/cli/output-formats) covers the global `--json`, `--jq`, `--template` and `--quiet` flags --- # l4 diff Source: https://docs.levelfour.ai/cli/commands/diff.md `l4 diff` shows the cost delta between your Terraform and CloudFormation as they are now and a baseline. By default the baseline is the git merge-base (`main` or `master`); you can also point it at a saved snapshot file. ```bash l4 diff main.tf l4 diff ./infra/ l4 diff template.yaml --parameters-file params/prod.json l4 diff --base main ./infra/ l4 diff baseline.json ./infra/ ``` > **Warning** > > The default baseline comes out of git history, and both GitHub Actions and GitLab CI check out a shallow clone, which leaves no merge-base to compare against. [CI/CD integration](/guides/ci-cd#diffing-a-change) sets the checkout depth each runner needs. Without history, diff against a saved baseline instead. ## Flags | Flag | Description | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--base ` | Git ref to diff against (default: auto-detect `main` or `master`) | | `--region ` | AWS region for pricing. Order: `--region`, then `region_override` in `.levelfour/config.yml`, then the template's `samconfig` (for Terraform, its provider block), then `us-east-1` | | `--gcp-region ` | GCP region (default: auto-detect or `us-central1`) | | `--azure-region ` | Azure region (default: auto-detect or `eastus`) | | `--var ` | Terraform variable (repeatable) | | `--var-file ` | Terraform variable file (repeatable) | | `--download-modules` | Download remote modules for accurate estimates (default `true`) | | `--parameter ` | CloudFormation parameter value, given to every root template that declares `Key` (repeatable). A nested stack gets the parameters its parent passes | | `--parameters-file ` | CloudFormation parameters file for the root templates, in the AWS CLI JSON format or the CodePipeline format | | `--config-env ` | The `samconfig` environment whose parameters and region apply (default `default`) | | `--fail-above ` | Exit code `2` if monthly delta exceeds this threshold | | `--format` | `table`, `json`, or `github-comment` (default `table`) | Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags). Against the merge-base, `l4 diff` uploads your CloudFormation templates as they are at the merge-base and as they are now. [`l4 estimate`](/cli/commands/estimate#cloudformation) covers what the upload holds and where parameter values come from. ## CI gate Fail the build when a pull request adds more than $100 a month: ```bash l4 diff --fail-above 100 -q ./infra/ ``` Over the threshold `l4 diff` exits `2` (`ExitIssuesFound`), which a job can tell apart from the `1` a generic failure returns. [Exit codes](/cli#exit-codes) has the full set. ## Comparing against a saved baseline Outside a git repository, or while you stage a multi-step deployment, diff against a snapshot that [`l4 estimate --out-file`](/cli/commands/estimate) wrote earlier. A snapshot holds Terraform resources only, so against one `l4 diff` prices every CloudFormation template as added: ```bash l4 estimate --out-file baseline.json ./infra/ # ... make changes ... l4 diff baseline.json ./infra/ ``` ## Posting to a pull request `--format github-comment` emits Markdown you can pipe into a comment: ```bash l4 diff --base main --format github-comment ./infra/ \ | gh pr comment "$PR_NUMBER" --body-file - ``` ## Related * [`l4 estimate`](/cli/commands/estimate): the absolute monthly cost, and the `--out-file` snapshot this command reads * [CI/CD integration](/guides/ci-cd): the GitHub Actions and GitLab CI jobs that run `l4 diff` on a pull request * [Output formats](/cli/output-formats): `--json`, `--jq`, `--template` and `--quiet` --- # l4 export Source: https://docs.levelfour.ai/cli/commands/export.md `l4 export` handles the pagination internally and emits a single, well-formed CSV. Use it when the destination is a spreadsheet, a BI tool, or a backup. ## Subcommands | Command | Purpose | | ------------------------------------- | ------------------------------------ | | [`costs`](#costs) | Export cost data over a time period | | [`recommendations`](#recommendations) | Export the full recommendations list | | [`commitments`](#commitments) | Export the commitment ledger | > **Note** > > `l4 costs --csv` and `l4 recommendations list --csv` are intentionally not supported. Those commands are paginated, and the per-page envelopes do not compose cleanly into a single CSV. [Output formats](/cli/output-formats#--csv) has what `--csv` does on every other command. ## costs ```bash l4 export costs [flags] ``` | Flag | Description | | --------------- | --------------------------------------- | | `--period

` | Time period: `7d`, `30d`, `90d`, `365d` | | `--account ` | Filter by account | | `--format ` | `csv` (default) or `json` | | `--out ` | Write to file instead of stdout | ```bash l4 export costs --format csv --period 90d > costs.csv l4 export costs --format json --period 30d --out costs.json l4 export costs --period 30d --account "123456789012 (prod)" > prod-costs.csv ``` ## recommendations ```bash l4 export recommendations [flags] ``` | Flag | Description | | --------------- | ------------------------------- | | `--account ` | Filter by account | | `--format ` | `csv` (default) or `json` | | `--out ` | Write to file instead of stdout | ```bash l4 export recommendations --format csv > recommendations.csv l4 export recommendations --format json --out recs.json ``` ## commitments ```bash l4 export commitments [flags] ``` | Flag | Description | | ----------------- | ------------------------------------ | | `--provider ` | `aws` or `gcp` (resolved if omitted) | | `--format ` | `csv` (default) or `json` | | `--out ` | Write to file instead of stdout | ```bash l4 export commitments --format csv > commitments.csv l4 export commitments --provider gcp --format json --out commitments.json ``` The export walks every page, so it is the whole ledger rather than the first two hundred rows. Columns differ by provider, because the two clouds expose different things: an AWS row carries the expiry instant and what a renewal protects, a Google Cloud row carries neither. A Savings Plan's coverage cell is empty rather than `0`, which is what a spreadsheet reads as no value. The reasons are in [`l4 commitments`](/cli/commands/commitments#not-measured-is-not-zero). ## Archiving to object storage Leave `--out` off and the export goes to stdout, so it can be piped straight into a bucket with no local file in between: ```bash DATE=$(date -u +%Y-%m-%d) l4 export costs --period 30d --format json | aws s3 cp - "s3://my-bucket/levelfour/costs-${DATE}.json" ``` ## Related * [Output formats](/cli/output-formats): `--csv`, `--json`, `--jq` and `--template` across every command * [Recipes](/cli/recipes#monthly-csv-archive): the month-stamped costs CSV archive and the rest of the shell pipelines * [`l4 costs`](/cli/commands/costs): the same cost data on screen, with filters and a TUI * [`l4 recommendations`](/cli/commands/recommendations): the paginated list this exports in full * [`/api-reference/costs`](/api-reference/costs) and [`/api-reference/recommendations`](/api-reference/recommendations): the underlying REST endpoints --- # l4 api Source: https://docs.levelfour.ai/cli/commands/api.md `l4 api` is the escape hatch: it sends an authenticated request to any LevelFour API endpoint without writing curl or composing headers yourself. It resolves a credential the same way every other `l4` command does. [CLI authentication](/cli/authentication) has that order. ```bash l4 api [flags] ``` The endpoint must start with `/`, for example `/api/v1/costs/summary`. > **Note** > > A read-only key fails on any endpoint that writes. [Key scopes](/authentication#key-scopes) has the split. ## Flags | Flag | Description | | ---------------------------- | ------------------------------------------------------ | | `-X` / `--method ` | HTTP method (default `GET`) | | `-f` / `--field ` | Request body field (repeatable, used for POST) | | `--include` | Include the HTTP status line and headers in the output | `l4 api` also takes the [global output flags](/cli/output-formats), including the `--jq` below. ## Examples **GET an endpoint:** ```bash l4 api /api/v1/costs/summary ``` **Filter the response with `--jq`:** ```bash l4 api /api/v1/recommendations --jq '.data.items[0]' ``` **POST with body fields:** ```bash l4 api -X POST /api/v1/api-keys -f name="CI Pipeline" -f scope=read ``` **Inspect the raw HTTP envelope:** ```bash l4 api /api/v1/costs/summary --include ``` > **Success** > > A `2xx` on the status line is the proof: the request reached the endpoint and the credential was accepted. ## When to use the escape hatch The [dedicated subcommands](/cli#command-groups) cover most workflows. They handle pagination, format the output for humans, and stay forwards-compatible. Reach for `l4 api` when: * No dedicated subcommand wraps the endpoint yet * You want to script against the raw API envelope * You are prototyping an integration before deciding whether to use an SDK > **Tip** > > For language-native integrations, prefer the [SDKs](/sdks) over `l4 api`. ## Related * [Output formats](/cli/output-formats): `--json`, `--jq`, `--template` and `--quiet` * [CLI authentication](/cli/authentication): the credential order, and the key scopes `l4 api` inherits * [API reference](/api-reference): every endpoint this command can reach --- # l4 config Source: https://docs.levelfour.ai/cli/commands/config.md `l4 config` reads and writes the CLI's persistent defaults, so you do not repeat the same flags on every command. `l4` keeps the config file in the platform-standard config directory (`~/.config/levelfour/` on Linux/macOS, `%APPDATA%\levelfour\` on Windows). ## Subcommands | Command | Purpose | | ------------------- | ---------------------------------------------------------- | | `list` | Show all configuration values and where each one came from | | `get ` | Print one config value | | `set ` | Set a config value | ```bash l4 config list l4 config get default_provider l4 config set default_provider aws ``` ## Resolution order `l4` resolves each settable value in this order. The first match wins: 1. Command-line flag (e.g. `--provider gcp`) 2. Environment variable (e.g. `LEVELFOUR_API`) 3. Config file (`l4 config set …`) 4. Built-in default > **Warning** > > A flag or an environment variable outranks the config file, so a value you set with `l4 config set` can sit in the file and never be the one in use. An exported `LEVELFOUR_API` you have forgotten about sends every command to another host. `l4 config list` annotates each value with its source, so read that before you decide the file is wrong. ## Common keys | Key | Purpose | | ------------------ | -------------------------------------------------------- | | `default_provider` | Default `--provider` for `costs` and `recommendations` | | `api_base` | Default API base URL (overrides built-in production URL) | ## Related * [Authentication](/cli/authentication): how `l4` resolves credentials, and pointing at another API host with `--api` or `LEVELFOUR_API` * [`l4 telemetry`](/cli/commands/telemetry): the opt-in flag stored alongside these settings * [CLI overview](/cli#global-flags): the flags every command accepts --- # l4 telemetry Source: https://docs.levelfour.ai/cli/commands/telemetry.md `l4 telemetry` controls whether the CLI sends panic stack traces and the failing command name to LevelFour. It is **off by default**. You opt in once with `l4 telemetry enable` and the setting survives upgrades. ```bash l4 telemetry enable l4 telemetry disable l4 telemetry status ``` ## What gets sent When telemetry is enabled and the CLI crashes: * The panic stack trace * The Cobra command name that triggered the panic (e.g. `recommendations list`) * The CLI version and platform string That is it. No flag values, no arguments, no environment, no file paths beyond what the stack trace itself contains. ## What gets scrubbed before transport A scrubber runs over every event before it leaves the process: * Home directory paths are rewritten to `~` * AWS access key IDs and secret access keys are redacted * Known token environment variables (`LEVELFOUR_TOKEN`, `GITHUB_TOKEN`, etc.) are redacted from event tags * All HTTP request headers and cookies are stripped ## Where the setting lives The opt-in flag is stored in your CLI config alongside settings managed by [`l4 config`](/cli/commands/config). It is not exported by `l4 config list`. Use `l4 telemetry status` to read it. ## When telemetry is silently a no-op Crash reports only flow when **both** conditions hold: * You ran `l4 telemetry enable`, and * The binary was built with a Sentry DSN baked in via release-time `-ldflags`. > **Note** > > Binaries built locally with `go install` or `go build` have an empty DSN and will never send anything, even if you opt in. Only the official release builds (from Homebrew, GitHub Releases, or `go install`'s package proxy at a tagged version) carry the DSN. > **Success** > > Both conditions holding is the working state: `l4 telemetry status` reports enabled, and the binary is an official release build. A crash then reaches us. ## Examples Check the current state in a script: ```bash if l4 telemetry status | grep -q enabled; then echo "Telemetry is on" fi ``` ## Related * [`l4 config`](/cli/commands/config): adjacent settings (default provider, default format, etc.) * [Reporting issues](/cli#reporting-issues): where to file a CLI bug when the crash is reproducible --- # Recipes Source: https://docs.levelfour.ai/cli/recipes.md Every recipe on this page is **validated against the live API** by `npm run validate:recipes` in CI. If a recipe stops working, the build fails. Substitute your own `recommendation_id` values and account IDs. ## Top 10 highest-savings recommendations The biggest wins, sorted server side and projected to the id, the service and the monthly saving: ```bash l4 recommendations list \ --sort-by monthly_savings --sort-order desc \ --page-size 10 \ --jq '.data.data.items[] | {id: .recommendation_id, service, savings: .monthly_savings}' ``` [`--jq`](/cli/output-formats#--jq) runs the filter in-process, so this recipe needs no pipe. ## Total savings grouped by service This groups a page of recommendations by `service` and ranks the groups by total potential savings: ```bash l4 recommendations list --page-size 100 --json | jq -r ' .data.data.items | group_by(.service) | map({service: .[0].service, count: length, total_savings: (map(.monthly_savings) | add * 100 | round / 100)}) | sort_by(-.total_savings) | .[] | [.service, (.count | tostring), "$" + (.total_savings | tostring) + "/mo"] | @tsv ' | column -t -s $'\t' ``` > **Warning** > > `--page-size 100` is the maximum, so this total covers the first page of recommendations and nothing after it. To cover every page, wrap the call in the `for` loop from [Aggregate 100% savings recommendations](#aggregate-100-savings-recommendations). ## Pending recommendations for one account Filter by account substring. The `account` field carries both the ID and the friendly name: ```bash l4 recommendations list --page-size 100 --json | jq -r ' [.data.data.items[] | select(.account | test("123456789012")) | select(.status == "pending" or .status == "available") ] as $matches | "\($matches | length) match(es), $\($matches | map(.monthly_savings) | add)/mo" ' ``` That count and that total also cover one page. Read the note under [Total savings grouped by service](#total-savings-grouped-by-service) before you quote either number. For bigger filtering jobs, prefer the server-side flags: ```bash l4 recommendations list --account "123456789012 (prod)" --status pending ``` ## Aggregate 100% savings recommendations Find every recommendation with `savings_percentage == 100`, meaning the resource can be removed entirely, iterate across all pages, and present a compact column-aligned report. CloudWatch entries are common and noisy, so the pipeline rolls them into a single row. ```bash TOTAL=$(l4 recommendations list --page-size 100 --json | jq '.data.data.pagination.total_pages') for p in $(seq 1 $TOTAL); do l4 recommendations list --page-size 100 --page $p --json done \ | jq -rs ' (map(.data.data.items) | add | map(select(.savings_percentage == 100))) as $recs | ($recs | map(select(.service != "CloudWatch"))) as $detail | ($recs | map(select(.service == "CloudWatch"))) as $cw | ($detail[] | [.recommendation_id, .service, (.environment // "-"), (.account // "-"), "$" + (.monthly_savings | tostring) + "/mo", .actions.overview] | @tsv), (if ($cw | length) > 0 then [ "[" + ($cw | length | tostring) + " items]", "CloudWatch", ([$cw[].environment] | unique | join(", ")), "multiple", "$" + ($cw | map(.monthly_savings) | add * 100 | round / 100 | tostring) + "/mo", "\($cw | length) CloudWatch recs aggregated (run with --service CloudWatch to list)" ] | @tsv else empty end), "TOTAL\t\t\t\t$" + (($recs | map(.monthly_savings) | add * 100 | round / 100) | tostring) + "/mo\t(" + ($recs | length | tostring) + " items)" ' \ | column -t -s $'\t' ``` **What this does:** 1. Reads `total_pages` from the first response to know how many pages to fetch. 2. Fetches each page with `--json` and concatenates the streams. 3. `jq -rs` slurps all pages into a single array, filters to 100% savings recs, splits CloudWatch off into its own bucket, and emits TSV rows. 4. `column -t` aligns the TSV into a readable table. **Sample output (sanitized):** ```text REC-001 S3 prod 123456789012 $9466.17/mo Convert data lake to Parquet (savings opportunity) REC-002 RDS gds 234567890123 $1293.74/mo Remove redundant cross-region backup copy REC-003 RDS prod 123456789012 $1206.43/mo Migrate db.m5 → db.r6g (Graviton) … [85 items] CloudWatch multi multiple $255.00/mo 85 CloudWatch recs aggregated (run with --service CloudWatch to list) TOTAL $3660.52/mo (114 items) ``` **JSON paths used:** `.data.data.pagination.total_pages`, `.data.data.items[].savings_percentage`, `.data.data.items[].service`, `.actions.overview`. See [`l4 recommendations`](/cli/commands/recommendations) for the full shape. ## Fetch full detail for the top five recommendations Pipe the top recs to `xargs` to enrich each one with the full `view` payload: ```bash l4 recommendations list \ --sort-by monthly_savings --sort-order desc --page-size 5 \ --json \ | jq -r '.data.data.items[].recommendation_id' \ | xargs -I{} l4 recommendations view {} --json ``` `--jq` cannot emit raw strings ([why](/guides/filtering-and-sorting#filtering-with---jq)), so this pipeline strips the quotes with a stand-alone `jq -r`. Use it for an archival snapshot, or to feed a tool that needs the implementation detail. ## Monthly CSV archive Archive the current month's costs as CSV, for BI tools that prefer flat files: ```bash PERIOD=$(date -u +%Y-%m) l4 export costs --period "$PERIOD" --format csv > "costs-${PERIOD}.csv" ``` > **Success** > > Nothing prints when this works. The file lands in the working directory as `costs-YYYY-MM.csv`, named from the `PERIOD` the first line computes. The `--period` flag accepts `YYYY-MM` for billing-month exports. Pair with `aws s3 cp` (or `gcloud storage cp`) to push to object storage. ## How recipes are validated The `npm run validate:recipes` script in `levelfour-docs` extracts the fenced bash blocks tagged with a `{/* recipe: */}` sentinel on this page and on [Filtering and sorting](/guides/filtering-and-sorting), runs each one through `bash -o pipefail -c`, and asserts a non-empty stdout (or zero-exit for snippets that redirect to a file). Auth resolves the same way as for any `l4` command, and the `LEVELFOUR_TOKEN` environment variable works in CI. To add a new recipe to this page, follow the same sentinel pattern and confirm the script picks it up: ```bash npm run validate:recipes ``` ## Next * [`l4 recommendations`](/cli/commands/recommendations): the `list` flags, the page cap and the JSON shape * [`l4 export`](/cli/commands/export): CSV and JSON dumps of costs and recommendations * [Output formats](/cli/output-formats): `--json`, `--jq`, `--template`, `--csv` and `--quiet` * [`l4 diff`](/cli/commands/diff#ci-gate): gate a build when a Terraform or CloudFormation change adds too much monthly cost * [`l4 commitments expiring`](/cli/commands/commitments/expiring): gate a build when a commitment is about to lapse * [CI/CD integration](/guides/ci-cd#wiring-it-into-a-pipeline): the GitHub Actions and GitLab CI jobs that run the gate on a pull request * [Filtering and sorting](/guides/filtering-and-sorting): the filter flags, the `--jq` quirks and the shorter worked queries --- # MCP Overview Source: https://docs.levelfour.ai/mcp.md LevelFour runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server at `https://mcp.levelfour.ai/mcp`. Point Claude Code, Claude, Cursor, VS Code, Windsurf or any other MCP client at it, and the model can read your cloud spend, the savings opportunities LevelFour has found, and what you have already booked. It serves the same data as the dashboard and the REST API, shaped so a model can ask for it in one call instead of five. > **Note** > > Your organization needs the MCP module, and only an organization administrator can approve a connection. Without the module the approval screen says so and nothing completes. Talk to your LevelFour contact. ## Connect ### Add the server ```bash claude mcp add --transport http levelfour https://mcp.levelfour.ai/mcp ``` Type the URL exactly as written, path included. A client that sends the bare host or a trailing slash will not match what the server advertises. ### Sign in Run `/mcp` inside Claude Code and choose **Authenticate**. Your browser opens the LevelFour approval screen, you sign in with the account you already have, and you approve the connection for one organization. No key is copied and nothing lands in a config file. The token expires in an hour, refreshes on its own, and works at this server and nowhere else. Read [Approving a connection](/mcp/approving-a-connection) before you click through it. ### Confirm it worked Ask your client to call `get-identity`: ```json { "plan": "enterprise", "enabled_providers": ["aws"], "executions_remaining": 12, "days_remaining": 30 } ``` > **Success** > > That answer is the proof: the credential is live and bound to an organization. A model answering a cost question is not proof, because it can answer badly for reasons that have nothing to do with the connection. If no tools appear at all, restart the client. Most read their MCP config once at startup. ### Ask it something > Why did our AWS bill go up last month, and what can we do about it? [Ask it this](/mcp/ask-it-this) has more that work as written, and the ones that do not. > **Tip** > > [`l4 mcp install`](/cli/commands/mcp/install) is the other way in. One command writes the server into every MCP client on the machine, across Claude Code, Claude Desktop, Cursor, VS Code and Windsurf. It installs an API key rather than signing you in, so reach for it when you want five clients wired at once, or when something runs unattended. ## The server | Property | Value | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Endpoint | `https://mcp.levelfour.ai/mcp` | | Transport | Streamable HTTP, stateless | | Authorization | OAuth 2.1 with PKCE, or a LevelFour API key in `Authorization: Bearer` | | Issuer | `https://mcp.levelfour.ai` | | Tools | Most read; the rest carry a savings from the decision to a rollout, define a virtual tag, or manage a cost alert, in LevelFour, never in your cloud. [Tools](/mcp/tools) lists every one | | Prompts | 5 | | Resources | 4 | | Rows per call | 50 maximum | Every tool resolves your organization from the credential, never from an argument. If you belong to two organizations, connect twice under different names and approve each separately. ## What it answers | Area | Tools | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Spend | `get-cost-summary`, `get-cost-breakdown`, `get-cost-series`, `get-cost-growth`, `get-usage-costs`, `get-cost-forecast` | | Spend by team | `get-costs-by-tag`, `get-tag` | | Defining a virtual tag | `preview-virtual-tag`, `create-virtual-tag`, `update-virtual-tag`, `delete-virtual-tag` | | Editing one of its configs | `create-virtual-tag-config`, `update-virtual-tag-config`, `delete-virtual-tag-config`, `update-virtual-tag-config-order` | | Cost alerts | `list-cost-alerts`, `list-cost-alert-events`, `get-cost-alert-suggestions`, `create-cost-alert`, `update-cost-alert`, `pause-cost-alert`, `delete-cost-alert` | | Savings opportunities | `list-recommendations`, `get-recommendation`, `get-savings-overview`, `get-savings-potential` | | Deciding and delivering | `decide-recommendation`, `set-implementation-method`, `get-savings-readiness`, `start-rollout` | | Applying it yourself | `get-manual-instructions` | | Automated Savings access | `get-savings-grant`, `create-savings-grant` | | Pull request delivery | `connect-version-control`, `list-repositories` | | Approvals | `list-approval-requests`, `release-rollout` | | Watching a rollout | `get-implementation-status`, `get-execution-logs` | | Realized savings | `get-savings-realized` | | Commitments | `get-commitment-overview`, `list-commitments`, `get-commitment`, `get-commitment-utilization`, `get-commitment-coverage`, `get-commitment-renewal-plan`, `get-commitment-renewal-options`, `get-commitment-purchase-plan`, `get-commitment-savings-rate`, `list-commitment-contracts` | | Buying and renewing commitments | `list-commitment-offerings`, `preview-commitment-purchase`, `create-commitment-purchase`, `update-commitment-purchase`, `create-commitment-renewal` | | Spikes | `list-anomalies`, `update-anomaly-status` | | Context | `get-identity`, `list-accounts` | > **Warning** > > Potential savings and realized savings are separate quantities. Potential is unbooked opportunity, realized is applied and measured. Adding them together double counts. The tools that write are spread across the rows above rather than gathered into one, because they no longer do one thing: a connection approved for changes can decide, choose how a change is delivered, clear what that format needs first, queue it, release a colleague's, close an anomaly, arm, change, silence or delete a cost alert, raise a commitment renewal or purchase, and define a virtual tag or edit one of its configs. None of them touches a cloud resource or buys anything: a raised renewal or purchase waits for an organization admin to release it. The tag tools change only how LevelFour allocates spend in its own reports. See [Approving a connection](/mcp/approving-a-connection). Alongside the tools the server ships 5 prompts and 4 resources. The prompts encode a full analysis in order, with the mistakes a model reliably makes already corrected. The resources tell a model which services, accounts, tags and environments your data actually contains, so it stops guessing at filter values. See [Prompts and resources](/mcp/prompts-and-resources). ## Next * [Connecting a client](/mcp/authentication) covers Claude Code, Claude, Cursor, VS Code and Windsurf, and the two credentials * [Approving a connection](/mcp/approving-a-connection) is what the browser asks you, and when to refuse * [Tools](/mcp/tools) is the generated reference, with every parameter, enum and default * [Reading a result](/mcp/results) is what comes back: the pagination envelope, truncation, limits and errors * [Ask it this](/mcp/ask-it-this) has questions that work as written * [CLI](/cli) has the write commands this server does not expose --- # Connecting a client Source: https://docs.levelfour.ai/mcp/authentication.md Two credentials reach this server, and the choice is about who is at the keyboard. | | Sign in | API key | | ------------------- | ------------------------------------------------ | ----------------------------- | | What you handle | Nothing | A secret you place and rotate | | Lifetime | One hour, refreshed silently | Until you revoke it | | Read or write | Chosen by the approver, per connection | Fixed by the key's scope | | Scoped to | One organization | One organization | | Where you revoke it | **Settings > API Keys > Connected Applications** | **Settings > API Keys** | | Good for | A person at an assistant | CI, a shared runner, a script | > **Tip** > > Prefer signing in. An API key is a password with no expiry that reads every cost figure in your organization, so save it for the cases where no browser is involved. Both reach the same tools, and both resolve your organization from the credential, never from an argument. Signing in is the only route your client can start on its own, which is why [`l4 mcp install`](/cli/commands/mcp/install) installs a key instead. ## Configure your client **Claude Code** ```bash claude mcp add --transport http levelfour https://mcp.levelfour.ai/mcp ``` Run `/mcp`, choose **levelfour**, then **Authenticate**. Your browser opens the approval screen. `--scope local` is the default and keeps the server to the current project. `--scope user` makes it available in every project. `--scope project` writes `.mcp.json` into the repository for your team, which works here because no credential is in the file: each teammate authenticates as themselves. Confirm it registered, and remove it: ```bash claude mcp list claude mcp get levelfour claude mcp remove levelfour ``` To send a key instead, add the header at registration time: ```bash claude mcp add --transport http levelfour https://mcp.levelfour.ai/mcp \ --header "Authorization: Bearer $LEVELFOUR_TOKEN" ``` > **Warning** > > `--header` is variadic, so it has to come after the name and the URL. Put it first and it swallows both, and the command exits having done nothing. **Claude** Claude connects over OAuth and has no field for a header, so signing in is the only route. In **Settings > Connectors**, add a custom connector with the URL `https://mcp.levelfour.ai/mcp`. Claude registers itself and sends you to the approval screen. Claude identifies itself with a metadata document served from its own domain, so this connection does not carry the unreviewed-application notice. [Approving a connection](/mcp/approving-a-connection#we-have-not-reviewed-this-application) explains why a locally installed client does. **Cursor** Write `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` for one: ```json title="~/.cursor/mcp.json" { "mcpServers": { "levelfour": { "url": "https://mcp.levelfour.ai/mcp" } } } ``` With no header, Cursor discovers that the server wants authorization and offers to sign you in. To send a key instead, add a `headers` block: ```json title="~/.cursor/mcp.json" { "mcpServers": { "levelfour": { "url": "https://mcp.levelfour.ai/mcp", "headers": { "Authorization": "Bearer ${env:LEVELFOUR_TOKEN}" } } } } ``` > **Tip** > > Cursor interpolates `${env:...}` in `url` and `headers`, so export `LEVELFOUR_TOKEN` in the shell that launches Cursor and the key stays out of the file. `LEVELFOUR_TOKEN` is the variable `l4` itself reads, so one export serves the CLI and the client. You commit a project-level `.cursor/mcp.json` with your code, which is the reason to keep keys out of it. **VS Code** Run **MCP: Add Server** from the Command Palette, or write `.vscode/mcp.json` in the workspace: ```json title=".vscode/mcp.json" { "servers": { "levelfour": { "type": "http", "url": "https://mcp.levelfour.ai/mcp" } } } ``` VS Code prompts you to sign in on first use. To send a key instead, add a `headers` block with an `inputs` entry, which keeps the value out of the file and in the editor's own secret storage: ```json title=".vscode/mcp.json" { "servers": { "levelfour": { "type": "http", "url": "https://mcp.levelfour.ai/mcp", "headers": { "Authorization": "Bearer ${input:levelfour-api-key}" } } }, "inputs": [ { "id": "levelfour-api-key", "type": "promptString", "description": "LevelFour API key", "password": true } ] } ``` **Windsurf** Write `~/.codeium/windsurf/mcp_config.json`. Windsurf spells the endpoint `serverUrl` where the others use `url`: ```json title="~/.codeium/windsurf/mcp_config.json" { "mcpServers": { "levelfour": { "serverUrl": "https://mcp.levelfour.ai/mcp", "headers": { "Authorization": "Bearer l4_live_your_key_here" } } } } ``` > **Warning** > > Windsurf does not interpolate environment variables, so a key sits in the file. Keep that file out of version control, and prefer the sign-in route if your version offers it. Restart Windsurf after saving. **Anything else** A client that speaks MCP over streamable HTTP connects the same way: point it at `https://mcp.levelfour.ai/mcp`. If it implements MCP authorization it will discover the server, register itself and send you to the approval screen. If it does not, give it an `Authorization: Bearer ` header. Goose, Zed and Cline all work. You write their config yourself. For a client that starts a local process rather than calling a URL, [`l4 mcp serve`](/cli/commands/mcp/serve) relays this server over stdio with the credential in your keychain, and offers what this server offers that key. ## Getting an API key **From the dashboard.** Open **Settings > API Keys**, create a key, and copy it. You see the value once. A `read` key reaches every tool that only reads. The ones that record a decision need a `read-write` key. See [Key scopes](/authentication#key-scopes) for the full split. **From the CLI.** [`l4 mcp install`](/cli/commands/mcp/install) uses the credential `l4` already holds and only opens the browser to mint a `read` key when there is none, so it doubles as the quickest way to get one. Pass `--token` to install a specific key instead. > **Tip** > > Treat the key like a password. It reads every cost figure and every recommendation in your organization. Keep it in an environment variable or your client's secret storage rather than in a file you commit. [Authentication](/authentication#security-best-practices) owns this. ## Troubleshooting A rejected credential returns `401` with a `WWW-Authenticate` header pointing at the server's protected resource metadata, which is how a client discovers where to authorize. The status alone does not separate the causes, so work through them in order. | Symptom | Cause | Fix | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401`, and the client offers to sign in | Normal on a first connection. The server is telling the client where to authorize | Complete the approval in the browser | | `401` after signing in | The token expired and the refresh failed, or somebody closed the connection in the dashboard | Authenticate again. A closed connection stays closed until you approve a new request | | `401` with a key | The key never arrived, is not an `l4_live_` or `l4_test_` value, or is revoked or expired. A dashboard session token is rejected before any lookup | Restart the client, confirm the header is present and starts with `Bearer `, and that the value is the API key itself. Create a new key if it is revoked | | `401` that no valid credential fixes | The organization does not have the MCP module. The server refuses the credential rather than naming the reason | Start the connection and read the approval screen, which does name it. Then talk to your LevelFour contact | | The approval screen says only an administrator can approve | Approving hands out a credential, so it is limited to administrators | Ask an administrator to start the connection themselves, rather than forwarding the link | | The approval request has expired | A request is live for ten minutes and can be used once | Start the connection again from the client | | `421 Misdirected Request` | The request arrived under a host the server does not answer on, usually a proxy rewriting `Host` | Point the client straight at `mcp.levelfour.ai` | | `429` | Too many requests on this credential or from this address | Back off for the seconds in `Retry-After`. [Reading a result](/mcp/results#errors) has the limits | | A tool reports a plan restriction | Your plan does not include that content | Call `get-identity` to see what the plan covers | | Tools missing after editing config | The client read its config at startup | Restart the client | ## Next * [Approving a connection](/mcp/approving-a-connection) is what the browser asks, and when to refuse * [Tools](/mcp/tools) is every tool, parameter, enum and default * [Reading a result](/mcp/results) is the shape of what comes back, and what an error looks like * [CLI authentication](/cli/authentication) covers keys for `l4`, including the read-write key its write commands need --- # Approving a connection Source: https://docs.levelfour.ai/mcp/approving-a-connection.md Connecting an assistant to LevelFour sends your browser to an approval screen on the dashboard. You sign in with the LevelFour account you already have, choose whether the assistant may act as well as read, and approve. Nothing is granted until you do. The screen shows three things, and each one is worth reading before you click. | On the screen | What it means | | ------------------------------ | ------------------------------------------------ | | **Connect X to LevelFour?** | The name the application registered under | | **Codes will be sent to** | The address that receives the authorization code | | **You are approving this for** | The organization that gets the access | ## "We have not reviewed this application" Some connections show a notice first, before the approval screen itself. > **Note** > > The notice is expected for a locally installed assistant. It is a statement about what LevelFour can verify, not a sign that something is wrong. Any application can register with the LevelFour MCP server without asking us. The protocol is designed that way: an assistant discovers the server, registers itself, and asks you for access. When it registers, it types its own name, so the name is not evidence of who wrote it. An application calling itself "Claude Code" was not necessarily written by Anthropic, and LevelFour cannot tell you either way. A hosted client is different. Claude and ChatGPT identify themselves with a metadata document served from `claude.ai`, `claude.com` or `chatgpt.com`, and serving that document requires control of the domain. Those connections skip the notice, because the domain proved what a typed name cannot. You will meet the notice again on a new machine, after reinstalling an assistant, and sometimes after an upgrade. Each registration is a separate application even when the name is identical, so approving one does not cover the next, and it does not cover a teammate. ### What carries signal instead The name proves nothing. Two other things on the screen do. **Did you start this?** You should have just run a connect command or clicked a connect button. If the page appeared on its own, or someone sent you the link, refuse. **Where will the codes go?** For an assistant on your own machine, expect `localhost` with a port, for example `http://localhost:3118/callback`. For a hosted assistant, expect that product's own domain. An address you do not recognize is the signal to refuse. > **Warning** > > LevelFour staff will never ask you to approve a connection. An approval link that arrives in a support conversation, an email or a chat did not come from us. Refuse it and tell your security contact. ## The organization You grant access to one organization: the one your session is currently in, shown under **You are approving this for**. The application does not choose it and cannot change it. If it names the wrong organization, refuse, switch organizations in the dashboard, and start the connection again. A connection to one organization cannot read another. If you work across two, connect twice and keep the entries under different names. ## Read, or read and act The **Also allow changes** checkbox is the whole difference between an assistant that reads your cost data and one that can also act on it. The application asks; you decide. Leave it off and every read tool still works. Refusing is enforced at the server, not left to the assistant to respect. The catalogue is filtered per credential, so a read-only connection never sees the tools that write, and an agent holding one cannot call them by name. Acting means changing something in LevelFour: accepting or rejecting a savings recommendation, settling how an accepted one gets delivered, clearing what that delivery format needs first, putting it in the rollout queue, releasing one a colleague asked for, or marking what you concluded about a detected spike. None of it reaches into your cloud accounts. LevelFour's own executor applies the change afterwards, and the two delivery formats that need your browser stay in your browser: Automated Savings applies through a CloudFormation stack you launch in your AWS console, and the IaC format opens a pull request against a repository bound to the recommendation. Clearing a prerequisite is a change too, and an approved connection can make all three. It can mint the scoped AWS access grant Automated Savings runs through, start a version control connection, and bind the repository a pull request lands on. Minting the grant hands out no access by itself: the permissions exist only once you launch the stack in your own console, and that is the step LevelFour cannot take for you. Releasing a rollout a colleague requested needs a connection an administrator approved for changes. An API key cannot do it, whatever its scope, because the permission is checked when the connection is approved rather than when the key is made. Approving is limited to organization administrators, because completing one hands out a credential. If you are not one, ask an administrator to start the connection themselves rather than forwarding them the link. The link is live, and anyone who opens it can approve on the organization's behalf. ## Refusing **Refuse** ends the request for good. The application is told, and the same link cannot be approved afterwards, so nobody can talk you into opening it again later. An approval request is live for ten minutes and can be used once. Leaving the page alone is enough to let one lapse. ## Seeing and closing connections **Settings > API Keys > Connected Applications** in the dashboard lists every approved connection: the application, the address its codes go to, whether it holds read or read and write access, who approved it, and when it was last used. **Close connection** takes effect immediately. The application cannot renew, and the credential it is holding stops working on its next call. It can ask again, which you are free to refuse. Close any connection you do not recognize, then tell your security contact. ## Next * [Connecting a client](/mcp/authentication) has the configuration each client expects * [Tools](/mcp/tools) is what an approved connection can call --- # Tools Source: https://docs.levelfour.ai/mcp/tools.md The server registers 60 tools, 21 of which write. This page is generated from `mcp-manifest.json`, the surface the running server advertises, so the names, parameters, enums and defaults below are the ones it registers. The text under each heading is what a model reads verbatim when it picks between them. ## Conventions Of the 60, 39 are annotated `readOnlyHint` true, `destructiveHint` false, `idempotentHint` true, `openWorldHint` false: they read, and the same arguments return the same answer. The other 21 write, marked below, and carry `readOnlyHint` false so your client asks you before calling one. None of them touches a cloud resource itself: they record a decision, settle how a change is delivered, or put it in the rollout queue, and LevelFour's own executor applies it afterwards. No tool takes a tenant, an organization or an account argument. The organization comes from the credential, because it is a separate Postgres database rather than a filter on a shared one. See [Connecting a client](/mcp/authentication). `provider` is a plain string, not an enum. Which providers exist is a fact about your organization, so the server validates the value at call time and names the valid ones in the error. Read `levelfour://providers` or call `get-identity` first. A default of `none` means the parameter is optional and omitted, and the description says what the server does without it. Dates are `YYYY-MM-DD`. Every `page_size` and `limit` caps at 50, and a list tool returns `has_next_page` and `next_page` already computed. Results come back as text rather than a structured content block. [Reading a result](/mcp/results) has the shapes, the pagination envelope every list shares, and what truncation and errors look like. | Tool | Mode | Purpose | | --------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`connect-version-control`](#connect-version-control) | write | Start connecting the organization's GitHub, which the IaC format needs. | | [`create-commitment-purchase`](#create-commitment-purchase) | write | Raise a new Savings Plan from its proposal, or a Reserved Instance or Savings Plan on terms of the user's own, as a savings nobody has decided yet. It raises and buys nothing. | | [`create-commitment-renewal`](#create-commitment-renewal) | write | Raise the renewal of an expiring Reserved Instance or Savings Plan as a savings. | | [`create-cost-alert`](#create-cost-alert) | write | Arm a cost alert. | | [`create-savings-grant`](#create-savings-grant) | write | Mint the scoped access grant that Automated Savings runs through, on AWS, or on Google Cloud for an organization that has Google Cloud Automated Savings turned on. | | [`create-virtual-tag`](#create-virtual-tag) | write | Define a new virtual tag from a full definition. | | [`create-virtual-tag-config`](#create-virtual-tag-config) | write | Add one config to a virtual tag, leaving the rest of it alone. | | [`decide-recommendation`](#decide-recommendation) | write | Record the user's decision to accept or reject one savings recommendation. | | [`delete-cost-alert`](#delete-cost-alert) | write | Delete a cost alert, and every firing it has recorded with it. | | [`delete-virtual-tag`](#delete-virtual-tag) | write | Delete a virtual tag and the spend allocation derived from it. | | [`delete-virtual-tag-config`](#delete-virtual-tag-config) | write | Remove one config from a virtual tag, leaving the rest of it alone. | | [`get-commitment`](#get-commitment) | read | One Reserved Instance or Savings Plan in full. | | [`get-commitment-coverage`](#get-commitment-coverage) | read | How much of the eligible bill is protected by a commitment, and how much is not. | | [`get-commitment-overview`](#get-commitment-overview) | read | The Reserved Instance and Savings Plan position, in aggregate. | | [`get-commitment-purchase-plan`](#get-commitment-purchase-plan) | read | What to commit to that is not committed to yet. | | [`get-commitment-renewal-options`](#get-commitment-renewal-options) | read | Every term an expiring commitment could be replaced with, priced and sized. | | [`get-commitment-renewal-plan`](#get-commitment-renewal-plan) | read | What to repurchase when a commitment expires, and the instant to buy after. | | [`get-commitment-savings-rate`](#get-commitment-savings-rate) | read | The effective savings rate (ESR): what the organization actually pays against public on-demand rates, after the commitment it paid for and left idle. | | [`get-commitment-utilization`](#get-commitment-utilization) | read | How much of what was bought is actually being used. | | [`get-cost-alert-suggestions`](#get-cost-alert-suggestions) | read | Alert rules this account's own billing history would support. | | [`get-cost-breakdown`](#get-cost-breakdown) | read | Break spend down into its largest components over a period. | | [`get-cost-forecast`](#get-cost-forecast) | read | Projected spend for the end of the current month. | | [`get-cost-growth`](#get-cost-growth) | read | Which services grew the most, and by how much. | | [`get-cost-series`](#get-cost-series) | read | Daily spend over a date range, as a time series. | | [`get-cost-summary`](#get-cost-summary) | read | Current cloud spend and where it is heading, or one month's total. | | [`get-costs-by-tag`](#get-costs-by-tag) | read | Spend grouped by the value of a tag, for showback and chargeback. | | [`get-execution-logs`](#get-execution-logs) | read | What one step of a rollout ran, and what it printed. | | [`get-identity`](#get-identity) | read | Identify the connected LevelFour organization and what it has access to. | | [`get-implementation-status`](#get-implementation-status) | read | Where one savings has got to: decided, waiting on an approver, or rolling out. | | [`get-manual-instructions`](#get-manual-instructions) | read | The step-by-step guide for applying one savings by hand. | | [`get-recommendation`](#get-recommendation) | read | The full case for one savings recommendation. | | [`get-savings-grant`](#get-savings-grant) | read | The state of the Automated Savings access grant on one recommendation. | | [`get-savings-overview`](#get-savings-overview) | read | How the savings backlog splits across its statuses, in count and in money. | | [`get-savings-potential`](#get-savings-potential) | read | Total unbooked opportunity across all recommendations, with per-status counts. | | [`get-savings-readiness`](#get-savings-readiness) | read | What still has to happen before a savings can be delivered each way. | | [`get-savings-realized`](#get-savings-realized) | read | Savings already realized and booked, with who approved them. | | [`get-tag`](#get-tag) | read | One tag key in full: how a virtual tag assigns its values, and where its spend lands. | | [`get-usage-costs`](#get-usage-costs) | read | Billable quantity and cost per unit, grouped by usage type, and what a usage type is. | | [`list-accounts`](#list-accounts) | read | The cloud accounts connected to this organization. | | [`list-anomalies`](#list-anomalies) | read | Detected cost anomalies, meaning unexpected spikes. | | [`list-approval-requests`](#list-approval-requests) | read | Rollouts waiting on an admin to release them, oldest first. | | [`list-commitment-contracts`](#list-commitment-contracts) | read | Contracted spend floors that behave like a commitment but appear in no provider commitment view. | | [`list-commitment-offerings`](#list-commitment-offerings) | read | What AWS sells for a Reserved Instance or Savings Plan the organization does not hold yet, one field at a time. | | [`list-commitments`](#list-commitments) | read | The individual Reserved Instances and Savings Plans, and what each one costs. | | [`list-cost-alert-events`](#list-cost-alert-events) | read | What one cost alert has said over time, newest first. | | [`list-cost-alerts`](#list-cost-alerts) | read | The organization's cost alerts and whether any is firing right now. | | [`list-recommendations`](#list-recommendations) | read | Savings opportunities LevelFour has found, largest first. | | [`list-repositories`](#list-repositories) | read | The organizations and repositories a savings can open its pull request against. | | [`pause-cost-alert`](#pause-cost-alert) | write | Silence a cost alert, or arm one that was silenced. | | [`preview-commitment-purchase`](#preview-commitment-purchase) | read | What one Savings Plan commitment, or one Reserved Instance or Savings Plan built by hand, would have done over the recent bill, before anything is bought. | | [`preview-virtual-tag`](#preview-virtual-tag) | read | Try a virtual tag's rules over real spend without saving anything. | | [`release-rollout`](#release-rollout) | write | Release a rollout a colleague asked for, as the admin answering them. | | [`set-implementation-method`](#set-implementation-method) | write | Record how an accepted savings should be delivered, and for a pull request, where it lands. | | [`start-rollout`](#start-rollout) | write | Put an accepted savings into the rollout queue. | | [`update-anomaly-status`](#update-anomaly-status) | write | Record what the user decided about one detected cost anomaly. | | [`update-commitment-purchase`](#update-commitment-purchase) | write | Re-size a purchase nobody has decided yet: a Savings Plan on today's proposal, or a purchase raised on terms of the user's own. It buys nothing. | | [`update-cost-alert`](#update-cost-alert) | write | Change a cost alert: its name, what it watches, its rule, or where it goes. | | [`update-virtual-tag`](#update-virtual-tag) | write | Replace an existing virtual tag's whole definition. | | [`update-virtual-tag-config`](#update-virtual-tag-config) | write | Replace one config of a virtual tag, leaving the rest of it alone. | | [`update-virtual-tag-config-order`](#update-virtual-tag-config-order) | write | Move one config of a virtual tag to another place in the order. | ## connect-version-control > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Start connecting the organization's GitHub, which the IaC format needs. **Returns:** the GitHub App install link. An organization owner opens it, chooses which repositories LevelFour may see, and GitHub then returns them to a LevelFour page that finishes the connection. Both halves are theirs to do: LevelFour cannot approve an app on their behalf. **When to use:** When `get-savings-readiness` reports no version control connection. **NOT for:** Choosing a repository for one savings, which is `set-implementation-method` once this is done. Not for GitLab either: a GitLab connection cannot carry the IaC format, because binding a repository to a recommendation keys on a GitHub installation. ### Parameters Takes no arguments. ## create-commitment-purchase > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Raise a new Savings Plan from its proposal, or a Reserved Instance or Savings Plan on terms of the user's own, as a savings nobody has decided yet. It raises and buys nothing. **Returns:** the savings it was raised as (its id starts BUY-), the payer and plan type, the 12-month No Upfront commitment in dollars an hour, the profile it came from (null for a size of the user's own) and the guards that capped it, the monthly savings it is booked at, grain "daily" and source "cur", whether this call created it, and `next`, the tools that carry it from here. With neither profile nor commitment\_hourly it raises balanced. Calling again with the same pick returns the purchase already raised; another pick is refused while it stands undecided, and with new\_purchase that refusal names it. An identical purchase already awaiting approval, accepted, released or sent in the last 24 hours is refused and named too, so ask the user before raising a second one with confirm\_additional. With new\_purchase it raises exactly that offering after checking AWS still sells it: kind, origin (recommended when it matches LevelFour's size, else custom), target, resource and quantity or the commitment, term, payment, the approval an admin accepts (term\_total, upfront, currency), its quote, and a Manual Setup guide. The buying account must be one of the organization's AWS accounts. **When to use:** The user read `get-commitment-purchase-plan`, `list-commitment-offerings` or `preview-commitment-purchase` and chose what to buy. **NOT for:** Changing the size of a purchase already raised, which `update-commitment-purchase` does. Replaying a size first, which `preview-commitment-purchase` does. Renewing a commitment that is expiring, which `create-commitment-renewal` does. ### Parameters | Parameter | Type | Default | Description | | -------------------- | ----------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `commitment_hourly` | number | none | A commitment of the user's own, in dollars an hour, instead of a profile. Rounded down to $0.001, and refused above AWS's own recommendation net of what is already bought. | | `confirm_additional` | boolean | `false` | True only when the user wants a second purchase beside one already there: identical to one awaiting approval, accepted, released, or sent to AWS in the last 24 hours, or, with new\_purchase, another pick for a target whose purchase is still undecided. Without it that raise is refused and names the purchase already there. | | `new_purchase` | string | none | A Reserved Instance or Savings Plan on terms of the user's own, in place of a proposal. kind reserved\_instance names service, region, engine, deployment, offering\_class and tenancy as `list-commitment-offerings` lists them, plus resource and quantity. kind savings\_plan names plan\_type, instance\_family and region (an ec2\_instance plan only) and commitment\_hourly. Both name term\_months, payment\_option, buying\_account\_id and the offering\_id the user chose. | | `payer_account_id` | string | none | The paying account the proposal was sized for. Omit when there is one. | | `plan_type` | `compute`, `database` | none | A proposal's plan type: compute covers EC2, Fargate and Lambda; database covers the database services a Database Savings Plan applies to, generation 7 and newer. Omit it with new\_purchase. | | `profile` | `conservative`, `balanced`, `max_savings` | none | A profile from the proposal in `get-commitment-purchase-plan`: conservative, balanced or max\_savings. Omit it when naming commitment\_hourly. | ## create-commitment-renewal > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Raise the renewal of an expiring Reserved Instance or Savings Plan as a savings. **Returns:** the savings the renewal was raised as (its id starts RENEW-), the offering and quantity it buys, whether this call created it, whether nobody has decided it yet (`rebindable`), the accepted changes that should land before it, and `next`, the tools that carry it from here. A Savings Plan renewal also carries `sized_on`: its quantity was sized on the billing export's daily totals, which can read higher than the quietest hour. Calling again returns the renewal already raised rather than a second one, and a renewal rejected before anybody released it is raised again as a new one. **When to use:** The user has read the renewal plan or its options and chosen what to renew. **NOT for:** Pricing the terms, which is `get-commitment-renewal-options`. Not for pointing an undecided renewal at another option either: that is refused here, and the user changes it in the Dashboard or rejects it with `decide-recommendation` and raises again. Nothing is bought here: the purchase waits for an organization admin's release. ### Parameters | Parameter | Type | Default | Description | | --------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `commitment_id` | string | required | The expiring reservation or plan to renew: its id from `list-commitments` (a plan's id is its ARN). | | `offering_id` | string | none | An offering\_id from `get-commitment-renewal-options`. Omit to take the recommended option. | | `quantity` | number | none | How many to buy on that offering: whole units for a reservation, dollars an hour for a Savings Plan. Needs offering\_id. | ## create-cost-alert > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Arm a cost alert. **Returns:** the stored rule. **When to use:** After `get-cost-alert-suggestions`, and only with a threshold the customer named or the suggestions justified. A commitment rule watches an expiry, a lapse, falling utilization, saturation or a contract overage on its own clock rather than the month's. **NOT for:** Changing an existing rule, which `update-cost-alert` does. Read `list-cost-alerts` first, because a second rule on the same slice notifies twice. ### Parameters | Parameter | Type | Default | Description | | ------------------- | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | required | What the alert is called, shown in the notification. | | `params` | object | required | The rule. A budget: `{kind: 'budget', budget: 40000, reset: 'monthly', rules: [{amount: 80, unit: 'percent'}]}`. A change: `{kind: 'changes', amount: 500, comparison: 'previous_7_days', floor: 100}`. A commitment: `{kind: 'commitment', watch: 'expiry', threshold: 30}`, where watch is one of expiry or lapsed (threshold in days), utilization (a percentage to fall below), saturation (a utilization percentage, paired with coverage\_below because full utilization is only bad news at low coverage), or contract\_overage, which takes no threshold and fires on the comparison itself. An unknown value is refused rather than guessed at. | | `recipient_emails` | array of string | required | Organization members to notify in the bell, and by Slack DM for those who switched it on. An address outside the organization cannot receive one and is refused. May be empty when the alert posts in a Slack channel. | | `slack_channel_ids` | array of string | none | Slack channel ids to post in, up to 10, such as C0123ABCD. Slack shows a channel's id at the bottom of its About tab. Leave out to post in the organization's default channel, if it has one; an empty list posts in no channel. A private channel needs @LevelFour invited first. | | `source_id` | string | required | An account id or cost view id from `list-cost-alerts`, or `aws` for a commitment rule, which watches every AWS commitment and fires for each one on its own. | ## create-savings-grant > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Mint the scoped access grant that Automated Savings runs through, on AWS, or on Google Cloud for an organization that has Google Cloud Automated Savings turned on. **Returns:** the new grant and the page where the customer authorizes it. Creating it changes nothing in their account: it writes a scoped policy and waits. On AWS the access exists only once they launch the CloudFormation stack themselves. On Google Cloud the result also carries `connect_command`, which they paste into their own Cloud Shell to bind the time-boxed roles. Neither is something LevelFour can do on their behalf. **When to use:** When the user wants an Automated savings to be able to run and no grant exists yet. This mints it and returns where they authorize it. **NOT for:** Checking on one, use `get-savings-grant`. Asking what a savings still needs, use `get-savings-readiness`. Making the change, which runs after the grant connects. Not for minting a second grant either: a live one already covers the recommendation, and two launched stacks add permissions to the role rather than replacing them, so that is refused here. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | -------- | -------------------------------------------------------------------- | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## create-virtual-tag > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Define a new virtual tag from a full definition. **Returns:** the tag as `get-tag` reads it, with the reprocessing `job`. Spend is reprocessed from effective\_from, so the figures fill in as the job runs. **When to use:** Only when the user asked, in this conversation, for a tag to be created. Try the rules with `preview-virtual-tag` first. **NOT for:** Changing a tag that exists, use `update-virtual-tag`. Provider tags, which are read from the bill and never modified. This changes how LevelFour allocates spend in its own reports, and nothing in a cloud account. ### Parameters | Parameter | Type | Default | Description | | ---------------- | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `can_override` | boolean | `false` | Only matters when the tag shares a provider tag's name. False keeps the provider value on lines that carry it; true lets the configs win and falls back to the provider value. | | `collapsed_keys` | array of string | none | Existing keys whose value this tag takes before any config is tried, in order. Each has a source (origin provider or virtual, and the key), an optional scope of providers and a value\_prefix. Use it to merge spellings such as team and Team into one key. | | `configs` | array of string | none | Configs in precedence order: the first whose filter and time\_frame match a cost line assigns it. A filter holds rules, any of which may match. A rule names its providers (aws, gcp) and conditions that must all hold. A condition has a dimension (account, billing\_account, region, service, category, subcategory, usage\_unit, resource, charge\_type, marketplace, tag, tagged, virtual\_tag), an operator (is, is\_not, contains, not\_contains, starts\_with, ends\_with, flexible\_match, not\_flexible\_match), values, and a key for tag, tagged and virtual\_tag. The assign is one of: type value with a value; type percent with splits of value and pct summing to 100; or type cost\_based with an input\_filter and a source key, splitting by each value's share of that key's spend on the input\_filter lines. | | `description` | string | `""` | What the tag is for, shown beside it. | | `effective_from` | string | none | First billing month the tag applies to, YYYY-MM, at most 12 months back. Omit for the current month. | | `name` | string | required | The tag's name, unique in the organization and matched case-insensitively. | ## create-virtual-tag-config > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Add one config to a virtual tag, leaving the rest of it alone. **Returns:** the tag as `get-tag` reads it, with the reprocessing `job`. **When to use:** Adding a value to a tag that exists, which is the common change. Prefer this over sending a whole definition back, because that clears anything you leave out. **Examples:** give the data team the Redshift spend: filter with one rule for aws whose condition is service is Amazon Redshift, assign type value with value data. **NOT for:** A tag that does not exist yet, use `create-virtual-tag`. The config lands last, which is lowest precedence, so if an earlier config already claims those lines it assigns nothing until you move it with `update-virtual-tag-config-order`. ### Parameters | Parameter | Type | Default | Description | | ------------------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assign` | string | required | What the matched lines are assigned: type value with a value, type percent with splits summing to 100, or type cost\_based with an input\_filter and a source key. Same shape as an assign inside `create-virtual-tag`. | | `expected_version` | integer | required | The tag's rules\_version as read from `get-tag`. Required, so a change made since it was read is never overwritten. | | `filter` | string | required | Which cost lines this config claims. Holds rules, any of which may match. A rule names its providers (aws, gcp) and conditions that must all hold. A condition has a dimension, an operator, values, and a key for tag, tagged and virtual\_tag. Same shape as a filter inside `create-virtual-tag`. | | `key` | string | required | The virtual tag's name or id. | | `time_frame` | string | none | Optional start and end dates, so the config only claims lines inside them. | ## decide-recommendation > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Record the user's decision to accept or reject one savings recommendation. **Returns:** the recommendation's new status and the decision as it was recorded. **When to use:** When the user has named a recommendation and said to accept or reject it. This books that decision, so it is the tool for "accept REC-1234" or "reject REC-5678". **NOT for:** Making the change. Accepting books a decision and stamps the approver; it does not resize, delete or modify anything. To settle how it gets delivered use `set-implementation-method`, and to put it in the rollout queue `start-rollout`. Not for reversing a decision, which only the dashboard can do. Not for a recommendation waiting on approval for a rollout a teammate requested either: answering them is `release-rollout`, and accepting here is refused. If the user has not seen the figures they are deciding on, `get-recommendation` puts the monthly saving and the operational impact in front of them first. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ---------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `decision` | `accepted`, `rejected` | required | 'accepted' books the recommendation as a decision to make the change. 'rejected' closes it. Neither touches a cloud resource. | | `explanation` | string | none | Free text backing the rejection reason, used when reason is 'other'. | | `reason` | `operational`, `strategy`, `not_applicable`, `other` | none | Why it was rejected. Only meaningful with decision='rejected', and optional even then: the user can add it later in the dashboard. Use 'other' with explanation when none of the fixed reasons fit. | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## delete-cost-alert > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Delete a cost alert, and every firing it has recorded with it. **Returns:** the id and name of the rule that is gone. **When to use:** Only when the user asked, in this conversation, for this rule to be deleted. Read it with `list-cost-alerts` first and name it back, because nothing brings it or its history back afterwards. **NOT for:** A rule firing on spend somebody accepted, which `pause-cost-alert` silences and keeps, history included. ### Parameters | Parameter | Type | Default | Description | | ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `alert_id` | string | required | Cost alert id, a UUID from `list-cost-alerts`. | | `name` | string | required | The alert's name as `list-cost-alerts` shows it. A name that is not this alert's is refused, so the rule deleted is the one that was read. | ## delete-virtual-tag > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Delete a virtual tag and the spend allocation derived from it. **Returns:** `outcome` deleted, with the name and id of the tag that went. **When to use:** Only when the user asked, in this conversation, for the tag to be deleted. Read it with `get-tag` first, both for its expected\_version and to report what is about to go. **NOT for:** Provider tags, which are read from the bill. A tag another virtual tag reads is refused rather than cascaded: change the tag that reads it first. ### Parameters | Parameter | Type | Default | Description | | ------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `expected_version` | integer | required | The tag's rules\_version as read from `get-tag`. Required, so a tag changed since it was read is never deleted on stale information. | | `name` | string | required | The virtual tag's name or id. | ## delete-virtual-tag-config > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Remove one config from a virtual tag, leaving the rest of it alone. **Returns:** the tag as `get-tag` reads it, with the reprocessing `job`. A value no remaining config assigns disappears with it, and the spend it held becomes unallocated. **When to use:** A team that no longer needs its own line. Read the tag with `get-tag` first and report which value goes, because the number alone does not say. **NOT for:** Removing the whole tag, use `delete-virtual-tag`. ### Parameters | Parameter | Type | Default | Description | | ------------------ | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expected_version` | integer | required | The tag's rules\_version as read from `get-tag`. Required, so a change made since it was read is never overwritten. | | `key` | string | required | The virtual tag's name or id. | | `position` | integer, min 1 | required | Which config, counting from 1 in the order `get-tag` lists them. Its configs are numbered, so read the tag first and use the number in front of the one you mean. | ## get-commitment **Purpose:** One Reserved Instance or Savings Plan in full. **Returns:** its term, payment option, holder account and the accounts consuming it, its utilization history, the events on it, and any savings attached to it. A reservation carries its coverage; a Savings Plan does not, because coverage is not reported by plan type. figures\_source "cur" means utilization, protects and rightsizing were read off the commitment's own lines on the bill; "cost\_explorer" means they are the service's figure shared by fee, an estimate to quote as one. **When to use:** After `list-commitments` names one worth opening. **NOT for:** Comparing several, use `list-commitments`. Planning a replacement, use `get-commitment-renewal-plan`. ### Parameters | Parameter | Type | Default | Description | | --------------- | ------ | -------- | ---------------------------------------------- | | `commitment_id` | string | required | The reservation or plan to open, by id or ARN. | ## get-commitment-coverage **Purpose:** How much of the eligible bill is protected by a commitment, and how much is not. **Returns:** coverage per service and the covered spend per consuming account, with the on-demand remainder beside it so the percentage names its own denominator, weighted by dollars. With months, AWS only, series holds the organization, organizations each payer and account\_series each account where workloads run, month by month. Coverage there compares instance hours at public on-demand prices, covered\_on\_demand against uncovered\_on\_demand. The commitment dollars sit apart: covered\_cost is what the account's usage consumed, shared\_in\_cost the part held by another account, shared\_out\_cost its own commitments used elsewhere, and held\_unused\_cost what they left idle after the organization shared them. A commitment idle in its holder's usage is not waste when shared\_out\_cost shows another account used it; only held\_unused\_cost is. utilization\_pct is of the commitments that covered the account. held\_unused\_cost and utilization\_pct are null while complete is false. A payer holding idle commitment with a cross\_account\_share\_pct under 5 may have sharing limited. For GCP, coverage and utilization are month to date per project and service, in projects. A spend-based commitment's fee may sit on its billing account, where its utilization is measured. **When to use:** "How exposed are we to on-demand rates", or before sizing a purchase. **NOT for:** Whether a commitment you already hold is being used, which divides by what was purchased rather than by the bill: use `get-commitment-utilization`. ### Parameters | Parameter | Type | Default | Description | | ---------- | ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `months` | integer, 0 to 12 | `0` | Calendar months of history to add, ending at the current month, as one series for the organization, one per payer and one per account where workloads run. AWS only. 0 adds none. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | ## get-commitment-overview **Purpose:** The Reserved Instance and Savings Plan position, in aggregate. **Returns:** the per-service split of utilization and coverage, what is committed, and how many commitments are live and how many expire soon. **When to use:** "How are we doing on commitments", as the first read before a narrower one. **NOT for:** A single figure for coverage or utilization, which are separate questions: `get-commitment-coverage` and `get-commitment-utilization`. The effective savings rate, use `get-commitment-savings-rate`. On-demand spend, use `get-cost-breakdown`. ### Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | ## get-commitment-purchase-plan **Purpose:** What to commit to that is not committed to yet. **Returns:** the on-demand base a Savings Plan could cover and no commitment does, per payer, plan type, service and platform, over the last 30 complete days of the daily bill, alongside the purchases that would cover it. Each row carries its average, its quietest day and suggested\_commitment\_hourly, that floor in plan commitment dollars an hour, and names its grain and source. A day averages its hours, so a daily floor sits at or above the hourly one: say so when sizing from it. A payer without detailed billing gets Cost Explorer's hourly figures with suggested\_commitment\_hourly 0, which is visibility, not a size. proposals holds one 12-month No Upfront Savings Plan per payer and plan type, sized on the daily floor over 60 days after what is already bought or agreed (ledger) and capped by AWS's own recommendation (aws): three profiles, conservative, balanced, the one recommended, and max\_savings, each in commitment dollars an hour with the utilization, coverage and monthly savings it would have had. unavailable\_reason says why a proposal carries no size, and raised names the purchase already raised from it that nobody has decided yet, or is null. reservations holds the new Reserved Instances LevelFour would buy for usage no Savings Plan covers (licence-included engines, Redshift, MemoryDB and older classes), each with its target, class, quantity, term, the offering it would buy, its quote over the measured usage, a note (licensed\_engine or size\_flexible), unavailable\_reason, quota\_blocked when the RDS quota cannot hold it, and raised. schedule holds the tranches of any purchase schedule, each with its size, day, status and the reason it last shrank or was skipped. automation, present only when the organization has commitments automation, holds the limits LevelFour buys within: mode (approval asks an admin before each purchase), paused, the monthly and per-purchase caps and what was bought this UTC month in commitment dollars an hour, the account that buys for each payer, and blockers. LevelFour buys on its own only in autonomous mode, and only a tranche whose access grant an admin connected. To replay any other commitment, use preview-commitment-purchase. AWS only. **When to use:** "What should we buy", or turning an uncovered base into a shortlist. **NOT for:** Replacing something that is expiring, use `get-commitment-renewal-plan`. Buying: `create-commitment-purchase` raises the proposal or reservation the user chooses as a savings, and buys nothing. ### Parameters Takes no arguments. ## get-commitment-renewal-options **Purpose:** Every term an expiring commitment could be replaced with, priced and sized. **Returns:** the contract priced again as it stands, beside each other term its shape is sold on today, with the quantity this customer's own usage supports, what each costs over the term and per year, and how much of each would sit idle or spill to on demand. sizing\_basis says where the quantity came from: "utilization" and "plan\_utilization" from the commitment's own fee line, which can only argue for buying less; "family" adds the on-demand usage of its family and engine held on nine days in ten, the only basis that recommends more than is held; "unmeasured" where no series could defend a number, so the quantity held stands. "measured" appears only on options an earlier sweep priced. decision "let\_expire" means the commitment was used too little to pay for itself: every term is priced at the quantity held for comparison and none is recommended. A size-flexible RDS, ElastiCache or MemoryDB reservation can be renewed as a larger class of its family: classes lists every class it was priced in and what one reservation of each covers, and the options are the class recommended unless resource names another. On an option, resource is the class bought, quantity is counted in that class, held\_units\_equivalent is the same quantity in the class held, and quota\_blocked means the account could not hold that many reservations in any class. The contract is always the class held. **When to use:** "Should we renew this as it stands", comparing a one year against a three year term or a payment option against the one running, or pricing the renewal as another class. **NOT for:** Buying. Nothing here places an order: `create-commitment-renewal` raises the option the user picks, by its offering\_id and quantity. ### Parameters | Parameter | Type | Default | Description | | --------------- | ------ | -------- | ------------------------------------------------------------------------------------- | | `commitment_id` | string | required | The expiring commitment to price alternatives for. | | `resource` | string | none | The class to price the renewal in, one of classes. Defaults to the class recommended. | ## get-commitment-renewal-plan **Purpose:** What to repurchase when a commitment expires, and the instant to buy after. **Returns:** units held against the average the sized series measured, the quantity the renewal options recommend (0 with decision "let\_expire" when it was used too little to pay for itself), the basis it was sized on, what the renewal protects kept separate from what right-sizing would save, and the pending changes that should land before the renewal so their capacity is not reserved. Units are in the class held; resource\_recommended and quantity\_recommended name the class the recommended option buys and how many, which differ where a size-flexible RDS renewal buys a larger class. superseded\_by names the commitment that already renewed this one, started at superseded\_at, and no\_recent\_usage means nothing of its family and engine ran last week; either way no renewal is raised. **When to use:** "What expires this month and what should I buy", or sizing a renewal. **NOT for:** Buying anything. `create-commitment-renewal` raises the renewal the user chooses, and nothing is bought until an organization admin releases it. Each pending change names a recommendation id: open it with `get-recommendation` and book it with `decide-recommendation`. ### Parameters | Parameter | Type | Default | Description | | --------------- | ------ | -------- | ------------------------------------- | | `commitment_id` | string | required | The commitment to plan a renewal for. | ## get-commitment-savings-rate **Purpose:** The effective savings rate (ESR): what the organization actually pays against public on-demand rates, after the commitment it paid for and left idle. **Returns:** the rate for the organization and for each account, splitting what a negotiated agreement earns from what commitments earn, plus how much of the scope could be measured. An account that bought nothing still shows a healthy blended rate, which is why the split matters. esr\_pct is the rate after waste: total savings minus unused\_commitment\_cost, over the on-demand equivalent, with idle commitment charged to the account that bought it. It is null for a month still being billed; for the headline figure, call again with period set to latest\_complete\_period. With months, series holds the organization, organizations each payer and account\_series each account, month by month, and complete false marks a month still being billed. AWS only; the on-demand baseline exists for no other provider. **When to use:** "What is our effective savings rate", or finding the accounts leaving money on the table. **NOT for:** Money already booked against a recommendation, use `get-savings-realized`. ### Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `months` | integer, 0 to 12 | `0` | Calendar months of history to add, ending at period or the latest month measured, as one series for the organization, one per payer and one per account. 0 adds none. | | `period` | string | none | The month to measure, YYYY-MM. Defaults to the latest. | | `scope` | `eligible`, `all` | `"eligible"` | eligible measures only commitment-eligible spend; all measures the whole bill. | ## get-commitment-utilization **Purpose:** How much of what was bought is actually being used. **Returns:** one entry per service for reservations, or per plan type for plans, each measured on its own, with the hours or units behind the percentage and a series over the window. **When to use:** "Are we paying for capacity nobody uses", or sizing down before a renewal. **NOT for:** How much of the bill a commitment protects, which is a different denominator and lives in `get-commitment-coverage`. Full utilization at low coverage is saturation, and the action it calls for is buying more. ### Parameters | Parameter | Type | Default | Description | | ------------- | ------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `end` | string | none | Last day to include, YYYY-MM-DD. | | `granularity` | `daily`, `monthly` | `"daily"` | Bucket size. | | `instrument` | `ri`, `sp` | `"ri"` | Which instrument to measure: ri for reservations, sp for savings plans. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | | `start` | string | none | First day to include, YYYY-MM-DD. | ## get-cost-alert-suggestions **Purpose:** Alert rules this account's own billing history would support. **Returns:** proposed rules in the same grammar a person would type, plus the baseline behind them and how much history stands behind it. **When to use:** Asked to set up alerting, or asked what a threshold should be. Propose from here rather than choosing a number: every figure is a statistic over this customer's own bill, so it can be defended. **NOT for:** An account connected too recently to calibrate, where `maturity` reads 'none' and no rule is proposed at all. ### Parameters | Parameter | Type | Default | Description | | ------------ | ------ | -------- | -------------------------------- | | `account_id` | string | required | Account id from `list-accounts`. | ## get-cost-breakdown **Purpose:** Break spend down into its largest components over a period. **Returns:** a paginated, sorted list of cost lines with amounts and shares. With group\_by, returns `totals` per value of that dimension instead of the list, with no pages to walk. **When to use:** After `get-cost-summary`, to answer "what is driving it". Ask one broad question first rather than one call per service. The window is one calendar month unless start\_date and end\_date are both given. For "what does each region cost", "compare our accounts" or any per-region, per-account or per-service total, pass group\_by rather than paging the lines and adding them up: a line is one service in one region in one account, so a month is thousands of them and a tally over pages is both slow and wrong. **Examples:** top services this month: no arguments. Largest accounts in July: period="2026-07", sort\_by="account\_id". What each region costs: group\_by="region". Compare accounts: group\_by="account\_id". Spend per team: group\_by="virtual\_tag", virtual\_tag\_key="Teams". One team's spend by service: group\_by="service", virtual\_tag\_key="Teams", virtual\_tag\_value=\["data"]. **NOT for:** Explaining a change over time, use `get-cost-growth`. Dating when a jump started, use `get-cost-series`. Savings opportunities, use `list-recommendations`. How a virtual tag assigns its values, use `get-tag`. ### Parameters | Parameter | Type | Default | Description | | ------------------- | -------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `end_date` | string, `YYYY-MM-DD` | none | End of the range, YYYY-MM-DD. Takes effect only together with start\_date: on its own it is ignored and the month in period is reported instead. | | `group_by` | `service`, `region`, `account_id`, `virtual_tag` | none | Total the spend by this dimension instead of listing cost lines. Returns one row per value with its cost, largest first, and no pages to walk. Each row also carries share\_of\_total, the same cost over the window before, and the change between them, all as the tool measured them, so a comparison needs no arithmetic of your own. previous\_cost is null for a group that carried no spend in the window before, and change\_vs\_previous is null when there is nothing comparable to measure against: no prior spend for that group, or a window still being billed. Grouping by account\_id adds account\_name, which is null when no name is on file, leaving the id as the only label that account has. The rows may be trimmed when there are many groups; total, when it is present, still covers every group rather than only the rows shown. A row flagged unassigned holds the spend that carries no value for that dimension, which on region is a global service, a Reserved Instance fee or a Savings Plan line. virtual\_tag totals one row per value of virtual\_tag\_key, with the spend no rule assigned under '**unallocated**'; beside a virtual\_tag\_key the other dimensions this takes are service, region and account\_id. page, page\_size, sort\_by and sort\_order are ignored when this is set. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `period` | string | none | Month to report on, YYYY-MM. Omit for the current month, or pass start\_date and end\_date together to report on an arbitrary window instead. | | `provider` | string | none | Cloud provider id, for example aws or gcp. Omit and the breakdown covers AWS only, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | | `sort_by` | `cost`, `service`, `region`, `account_id`, `change_percentage` | `"cost"` | Field to sort by. | | `sort_order` | `asc`, `desc` | `"desc"` | Sort direction. | | `start_date` | string, `YYYY-MM-DD` | none | Start of the range, YYYY-MM-DD. Takes effect only together with end\_date: on its own it is ignored and the month in period is reported instead. | | `virtual_tag_key` | string | none | A virtual tag's name or id. Reports the spend its rules assigned rather than the raw bill, and needs group\_by. One key per call: each covers the whole bill, so two would count it twice. | | `virtual_tag_value` | array of string | none | Values of virtual\_tag\_key to keep, so group\_by then splits those values alone. '**unallocated**' keeps the spend no rule assigned. Needs virtual\_tag\_key. | ## get-cost-forecast **Purpose:** Projected spend for the end of the current month. **Returns:** the forecast figure, its confidence range, and the basis it was derived from: how much is already booked, the daily run rate, how many days are left to project, the day the actuals stop (`actuals_through`), how many of the trailing 30 days the run rate was drawn from (`daily_rate_sample_days`) and how many of those days carried almost nothing (`daily_rate_thin_days`). **When to use:** "Where will we land this month." Read the basis before quoting the figure. A run rate drawn from a handful of days, actuals that stopped several days ago, or thin days in the window all mean the projection rests on less than it looks, and that belongs in the answer. Thin days usually mean an account stopped delivering billing data rather than that spend fell, so `list-accounts` is the next call rather than a conclusion. **NOT for:** Historical spend, use `get-cost-summary`. A horizon beyond this month, which LevelFour does not forecast. LevelFour has no budget object either, so if the user asks whether they will exceed a budget you must ask them for the number. ### Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | ## get-cost-growth **Purpose:** Which services grew the most, and by how much. **Returns:** services ranked by how many dollars they added, with the percentage beside each, plus the two windows compared and how many days each covers, so a partial month is visible rather than silent. **When to use:** "Why did the bill go up." Keep the default compare\_to unless the user asked specifically about calendar months. **NOT for:** Absolute spend ranking, use `get-cost-breakdown`. Unexpected spikes specifically, use `list-anomalies`. ### Parameters | Parameter | Type | Default | Description | | ------------ | ----------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `compare_to` | `previous_period`, `previous_month` | `"previous_period"` | Baseline window. 'previous\_period' compares the last 30 days against the 30 before them and is the reliable default. 'previous\_month' compares this month so far against the whole of last month, so early in a month it understates growth. | | `limit` | integer, 1 to 50 | `10` | Maximum rows to return. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | ## get-cost-series **Purpose:** Daily spend over a date range, as a time series. **Returns:** one point per day with the total for that day, and `providers_covered` naming the clouds those totals span. This is the only cost tool that covers every connected cloud at once, so a figure from here is not comparable with one from a single-provider tool unless `providers_covered` says it is. **When to use:** To date a step change. When the bill jumped and you need to know which day it started, this is the only tool that can tell you. Pair it with `get-cost-breakdown` over the same window to attribute the jump. A day missing from the series had no rows at all, which is different from a day that cost nothing. **NOT for:** Month totals, use `get-cost-summary`. Splitting a day by service, use `get-cost-breakdown` for that window. ### Parameters | Parameter | Type | Default | Description | | --------- | -------------------- | ------- | ------------------------------------------------------------------------ | | `end` | string, `YYYY-MM-DD` | none | End of the range, YYYY-MM-DD. Omit for today. | | `start` | string, `YYYY-MM-DD` | none | Start of the range, YYYY-MM-DD. Omit for the first of the current month. | ## get-cost-summary **Purpose:** Current cloud spend and where it is heading, or one month's total. **Returns:** with a period, that month's spend and its change on the previous month, and nothing else. Without one: total spend, per-provider totals, month-over-month change and the monthly series. **When to use:** The opening question, "what are we spending". For "what did we spend in July", pass period="2026-07" rather than reading it out of the series. **NOT for:** Spend split by service, account or tag, use `get-cost-breakdown`. A day-level series, use `get-cost-series`. ### Parameters | Parameter | Type | Default | Description | | --------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `period` | string | none | Month to report, YYYY-MM. Pass it when the question names one month, and the answer is that month's total and its change on the month before. Omit it for the current position, which also carries the forecast, potential savings and the monthly series. | ## get-costs-by-tag **Purpose:** Spend grouped by the value of a tag, for showback and chargeback. **Returns:** spend per tag value, plus allocation coverage showing how much spend carries the tag. Called with no tag\_key, returns the keys available, each with its `origin`: provider for a cost allocation tag read from the bill, virtual for a tag LevelFour assigns from rules. A virtual key also returns `unallocated`, the spend no rule assigned. **When to use:** "What does team X cost", or any per-team, per-environment or per-owner question. Read the coverage figure, or `unallocated`, before reporting a total: untagged spend is not zero spend. **Examples:** discover keys: no arguments. Then: tag\_key="team". **NOT for:** Per-service or per-account splits, use `get-cost-breakdown`, which takes the same virtual\_tag\_key beside a group\_by. How a virtual tag assigns its values, use `get-tag`. ### Parameters | Parameter | Type | Default | Description | | ---------- | -------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `end` | string, `YYYY-MM-DD` | none | End of the range, YYYY-MM-DD. Omit for today. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | | `start` | string, `YYYY-MM-DD` | none | Start of the range, YYYY-MM-DD. Omit for the first of the current month. | | `tag_key` | string | none | Tag key to group by: a provider tag key, or a virtual tag's name or id. Call with no tag\_key to list the keys available. | ## get-execution-logs **Purpose:** What one step of a rollout ran, and what it printed. **Returns:** the commands and output recorded for a single step. These are LevelFour's own execution records, written by whatever applied the change; report them as such rather than as something you ran. **When to use:** When the user asks what a rollout step actually ran or printed. **NOT for:** The shape of the rollout, use `get-implementation-status`, which lists the steps and their states far more cheaply. Reading every step at once: output here is unbounded operator text, so this serves one step and names the others. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | | `step_id` | string | none | Step to read, from `execution_steps` on `get-implementation-status`. Omit for the step that failed, or the last step to run when none failed. | ## get-identity **Purpose:** Identify the connected LevelFour organization and what it has access to. **Returns:** organization plan, the cloud providers that have data, and remaining evaluation runs. **When to use:** To check the connection works, or when a provider argument was rejected and you need the valid values. You do not need to call this before every analysis. **NOT for:** Cloud account ids or names, use `list-accounts`. Spend figures, use `get-cost-summary`. ### Parameters Takes no arguments. ## get-implementation-status **Purpose:** Where one savings has got to: decided, waiting on an approver, or rolling out. **Returns:** the decision, the chosen delivery format, whether that format can actually run and what is blocking it if not, who asked for a rollout still waiting on approval, and the steps of a rollout under way with the state of each. **When to use:** "Did that go through", "what is it waiting on", "why has nothing happened". Also before `start-rollout`, because it names the blocker in the same words that tool refuses with. **NOT for:** What a savings needs before it can start, use `get-savings-readiness`. The commands a step ran, use `get-execution-logs`. The case for the savings, use `get-recommendation`. Watching in a loop: a rollout can take 12 to 72 hours to begin, so an empty pipeline means it has not started, not that it is stuck, and the answer to "is it done yet" is another call later rather than another call now. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | -------- | -------------------------------------------------------------------- | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## get-manual-instructions **Purpose:** The step-by-step guide for applying one savings by hand. **Returns:** the customer-facing instructions as markdown, written for the person doing the work in their own cloud console, plus the resources the change touches. **When to use:** When the delivery format is manual and the user is ready to make the change, or is deciding whether they can. This is the whole of the manual path: nothing is queued and no rollout runs, because the customer applies it themselves. **NOT for:** The other formats. Automated Savings and IaC are applied by LevelFour and have their own prerequisites, which `get-savings-readiness` names. Not for whether the work is done either: that is settled separately and `get-implementation-status` reports it. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | -------- | -------------------------------------------------------------------- | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## get-recommendation **Purpose:** The full case for one savings recommendation. **Returns:** financials, the resources in scope, what will change, risk assessment, metrics, the delivery formats this savings backs in `available_methods` and the one chosen in `implementation_method`, where the decision stands, and `dashboard_url`, the saving's page in LevelFour, which is the link to give anyone asking for this saving's link. `resource_console_url` opens the resource in the cloud console instead. `display_status` is the word every LevelFour surface prints; `status` here is the stored column and can read differently. An id that names no saving returns `status` `not_found` with the id as typed in `requested_id` and up to three `candidates`, the closest ids, so answer from those rather than guessing another. **When to use:** After `list-recommendations`, when the user asks about a specific id, and before recording any decision on it. **NOT for:** Making the change. To record the user's accept or reject, use `decide-recommendation`; to settle the delivery format, `set-implementation-method`; to start the rollout, `start-rollout`. Not for a rollout already under way either, use `get-implementation-status`. Not for the hands-on guide: `manual_instructions` is withheld here and `get-manual-instructions` serves it. Not for scanning the backlog, use the list tool. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `recommendation_id` | string | required | Recommendation id, for example REC-1234. Pass it the way the person typed it, rec 1234 or REC1234 included: case, spacing and leading zeros are matched, and an id that names no saving is answered with the closest ones. | ## get-savings-grant **Purpose:** The state of the Automated Savings access grant on one recommendation. **Returns:** the grant's status, when it expires, what it is scoped to, and where the customer authorizes it. A grant reaches 'connected' only when they launch it in their own AWS console, which LevelFour cannot do for them and cannot hurry. **When to use:** After `create-savings-grant`, to see whether the customer has authorized it yet, or when `get-savings-readiness` reports a grant blocker and you want the detail. **NOT for:** Creating one, use `create-savings-grant`. Whether the savings itself can run, use `get-savings-readiness`, which weighs this alongside everything else. Do not poll this in a tight loop: authorizing is a person opening a console, so ask again later rather than again now. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | -------- | --------------------------------------------------------------------------------- | | `grant_id` | string | none | Grant id from `get-savings-grant` or `create-savings-grant`. Omit for the latest. | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## get-savings-overview **Purpose:** How the savings backlog splits across its statuses, in count and in money. **Returns:** a count and a monthly savings figure per status (available, pending, awaiting\_approval, processing, optimized, rejected, unavailable) plus the total number of recommendations, for one provider. **When to use:** "How many savings are waiting on us", "how much is still open", or to size a status before listing it. Cheaper than paging `list-recommendations` to count rows. **NOT for:** The individual opportunities, use `list-recommendations`. The organization-wide unbooked total, use `get-savings-potential`. Savings already booked, use `get-savings-realized`. These are separate quantities and summing across them is wrong: the optimized bucket is work already applied and the unavailable bucket is locked by the plan. ### Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | ## get-savings-potential **Purpose:** Total unbooked opportunity across all recommendations, with per-status counts. **Returns:** open potential savings split by provider, and a per-status breakdown of the whole catalog. The per-status figures are separate quantities and must not be summed: optimized is work already applied, unavailable is locked by the plan. **When to use:** "How much could we save in total", or to size the backlog before listing it. **NOT for:** Savings already achieved, use `get-savings-realized`. Individual opportunities, use `list-recommendations`. ### Parameters Takes no arguments. ## get-savings-readiness **Purpose:** What still has to happen before a savings can be delivered each way. **Returns:** one entry per format this savings backs, each saying whether it can run now and, when it cannot, the single thing that would clear it and the tool that does so. A ready IaC format also names the repositories its pull request would open against, which is usually a binding made long before this conversation. Pass a method to ask about one. **When to use:** Before `start-rollout`, or whenever the user asks why a savings has not moved. It answers "what do I need" in one call rather than a refusal at a time. **NOT for:** Where a rollout that already started has got to, use `get-implementation-status`. The case for the savings, use `get-recommendation`. Polling this while a customer authorizes a grant is fine, but a grant can take a while to connect, so leave time between calls. ### Parameters | Parameter | Type | Default | Description | | ------------------- | -------------------------------------------------- | -------- | ------------------------------------------------------------------------ | | `method` | `one-click`, `iac`, `one-click-plus-iac`, `manual` | none | Ask about one delivery format. Omit for every format this savings backs. | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## get-savings-realized **Purpose:** Savings already realized and booked, with who approved them. **Returns:** realized totals, ROI and payback, plus a line-item history. **When to use:** "How much have we actually saved", or any question about past results. Each ledger row carries a full monthly rate, so a sum across rows is a run rate, not a period total. **NOT for:** Opportunities not yet applied, use `get-savings-potential`. Realized and potential savings must never be added together. ### Parameters | Parameter | Type | Default | Description | | ------------- | -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account_id` | array of string | none | Restrict to these values. Omit for all. | | `end` | string, `YYYY-MM-DD` | none | End of the range, YYYY-MM-DD. Omit to search up to the most recent record. | | `environment` | array of string | none | Restrict to these values. Omit for all. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. Omit for every provider this organization has. Call `get-identity` if you are unsure which are available. | | `service` | array of string | none | Restrict to these values. Omit for all. | | `start` | string, `YYYY-MM-DD` | none | Start of the range, YYYY-MM-DD. Omit to search from the earliest record. | ## get-tag **Purpose:** One tag key in full: how a virtual tag assigns its values, and where its spend lands. **Returns:** spend and resource count per value and `unallocated_spend`. For a virtual tag, also its collapsed keys and configs as sentences in precedence order, `definition` with the same rules in the shape `update-virtual-tag` takes, `rules_version`, and the latest reprocessing `job`. Figures are not final while that job runs. **When to use:** "How is our Teams tag defined", "why is this cost unallocated", or before changing a virtual tag: edit its definition and send it back with expected\_version set to its rules\_version. **NOT for:** Spend split by service within each value, use `get-costs-by-tag`. Trying rules out before saving them, use `preview-virtual-tag`. ### Parameters | Parameter | Type | Default | Description | | --------- | -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `end` | string, `YYYY-MM-DD` | none | Last day of the window, YYYY-MM-DD. Omit for yesterday. | | `key` | string | required | The tag key: a virtual tag's name or id, or a provider tag key's name. `get-costs-by-tag` with no arguments lists them. | | `start` | string, `YYYY-MM-DD` | none | First day of the window, YYYY-MM-DD. Omit for 30 days before end. | ## get-usage-costs **Purpose:** Billable quantity and cost per unit, grouped by usage type, and what a usage type is. **Returns:** a paginated list of spend, quantity, unit and derived unit cost per usage type, largest first. A row whose quantity is not a countable unit carries no derived rate. Name a `usage_type` and each row also carries the services charging it, the regions and accounts it appears in and the days it was billed on. **When to use:** To ground a savings claim in measured consumption, to tell a price change apart from a volume change, or to say what a code on the bill means. `USE1-CachedData:Redis` is answered by reading it here, because the row names the service and the region. **NOT for:** Spend totals, use `get-cost-breakdown`. Note that usage quantity, not cost, is the reliable idleness signal here, because the cost columns round to cents. ### Parameters | Parameter | Type | Default | Description | | ------------ | -------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `end` | string, `YYYY-MM-DD` | none | End of the range, YYYY-MM-DD. Omit for today. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `provider` | string | none | Cloud provider id, for example aws or gcp, or 'all'. Both 'all' and omitting it read the AWS usage tables, so name gcp explicitly to see GCP usage. Call `get-identity` to see which are available. | | `start` | string, `YYYY-MM-DD` | none | Start of the range, YYYY-MM-DD. Omit for the first of the current month. | | `usage_type` | string | none | Narrow to the usage types whose code contains this text, case-insensitively, for example USE1-CachedData:Redis or NatGateway. Naming one also reports which services charge it, which regions and accounts it appears in and how many days it was billed on, which is what says in plain words what the code is. Omit to rank every usage type by spend. | ## list-accounts **Purpose:** The cloud accounts connected to this organization. **Returns:** account id, name, provider, region, connection status, and whether detailed billing data is flowing for each. **When to use:** To turn an account id seen in a cost or recommendation row into a name, to answer "which accounts are connected", or to check why an account has no data. **NOT for:** Spend per account, use `get-cost-breakdown` with sort\_by="account\_id". ### Parameters | Parameter | Type | Default | Description | | ----------- | ---------------- | ------- | ---------------------- | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | ## list-anomalies **Purpose:** Detected cost anomalies, meaning unexpected spikes. **Returns:** anomalies with service, magnitude, detection time and status, plus counts by severity. **When to use:** "Did anything spike", or investigating an unexpected increase in a past month. With no start and end this spans every anomaly ever detected, so pass both to ask about one month. The window is matched against the day a spike began, not the day it was detected. **NOT for:** Expected or gradual growth, use `get-cost-growth`. ### Parameters | Parameter | Type | Default | Description | | ----------- | --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `end` | string, `YYYY-MM-DD` | none | End of the range, YYYY-MM-DD. Omit to search up to the most recent record. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. Omit for every provider this organization has. Call `get-identity` if you are unsure which are available. | | `severity` | `critical`, `warning` | none | Restrict to one severity. Omit for all. | | `start` | string, `YYYY-MM-DD` | none | Start of the range, YYYY-MM-DD. Omit to search from the earliest record. | | `status` | `active`, `resolved`, `dismissed` | none | Restrict to one anomaly status. Omitting it returns active and resolved only, so ask for 'dismissed' explicitly to see the ones marked not real. | ## list-approval-requests **Purpose:** Rollouts waiting on an admin to release them, oldest first. **Returns:** the recommendation, its monthly saving, who asked, when they asked, and the delivery format they proposed, which can be absent. **When to use:** "What is waiting on me", "what needs approving". Then `release-rollout` to release one. **NOT for:** The savings backlog, use `list-recommendations`. Deciding whether a savings is worth doing, open it with `get-recommendation` first: this answers who is waiting, not whether to say yes. ### Parameters Takes no arguments. ## list-commitment-contracts **Purpose:** Contracted spend floors that behave like a commitment but appear in no provider commitment view. **Returns:** each vendor's contracted monthly floor beside the metered usage billing on top of it, with the months behind both, and whether the overage has passed the floor it sits under. **When to use:** "What else are we committed to", or when a marketplace bill grows faster than the contract that was meant to cap it. **NOT for:** Reserved Instances and Savings Plans, use `list-commitments`. ### Parameters Takes no arguments. ## list-commitment-offerings **Purpose:** What AWS sells for a Reserved Instance or Savings Plan the organization does not hold yet, one field at a time. **Returns:** choices: for every field of the target, the values sold given every field before it, in the order each narrows the next. A reservation's fields are service, region, engine, deployment, offering\_class, tenancy and resource; a plan's are plan\_type, instance\_family and region. A field with no choices does not apply and is left out. complete says the target is whole, and offers then lists every offering sold for it: offering\_id, resource, term\_months, payment\_option, currency, upfront and recurring\_hourly per unit (a reservation, or a dollar an hour of a plan), term\_total, effective\_hourly, and the public on-demand rate where the price list has one. Regions are where the organization's accounts live, hold commitments or run anything. fetched\_at says when AWS was read; every page is kept a day. Buys nothing. AWS only. **When to use:** Building a purchase by hand, including for infrastructure that does not run yet: call it field by field until complete, then quote the offer with `preview-commitment-purchase` and raise it with `create-commitment-purchase`, both passing its offering\_id in new\_purchase. **NOT for:** What LevelFour recommends buying, which `get-commitment-purchase-plan` sizes. Renewing a commitment already held, which `get-commitment-renewal-options` prices. ### Parameters | Parameter | Type | Default | Description | | ----------------- | ----------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | | `deployment` | `single_az`, `multi_az` | none | RDS only: single\_az or multi\_az. | | `engine` | string | none | The product description, as choices lists it: postgresql, Linux/UNIX. | | `instance_family` | string | none | An ec2\_instance plan's family: m6i. | | `kind` | `reserved_instance`, `savings_plan` | required | reserved\_instance for a Reserved Instance, savings\_plan for a Savings Plan. | | `offering_class` | `standard`, `convertible` | none | EC2 only: standard or convertible. | | `plan_type` | `compute`, `database`, `ec2_instance` | none | A plan's type: compute, database or ec2\_instance. | | `region` | string | none | The region, such as us-east-1. A plan names one only for ec2\_instance. | | `resource` | string | none | The class, such as db.r6g.large. EC2 lists its offers only once one is named. | | `service` | `ec2`, `rds`, `elasticache`, `opensearch`, `redshift`, `memorydb` | none | A reservation's service: ec2, rds, elasticache, opensearch, redshift or memorydb. | | `tenancy` | `default`, `dedicated` | none | EC2 only: default or dedicated. | ## list-commitments **Purpose:** The individual Reserved Instances and Savings Plans, and what each one costs. **Returns:** each commitment with its kind, service, term, monthly fee, the account holding it against the accounts consuming it, and the instant it ends rather than the day. Totals come with it. A commitment is held on a payer and consumed elsewhere, so the holder answering for an account that looks uncovered is the normal case, not an error. **When to use:** "What do we hold and what does it cost", or "what expires before the quarter ends" and sizing the cliff when that usage reverts to on-demand rates. **NOT for:** Coverage or utilization, which are separate questions with separate denominators: `get-commitment-coverage` and `get-commitment-utilization`. ### Parameters | Parameter | Type | Default | Description | | ----------- | ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `basis` | `net`, `list` | `"net"` | net is what the organization is charged; list is the undiscounted rate. | | `kind` | string | none | Restrict to one kind: reserved\_instance or savings\_plan on AWS, committed\_use\_discount or spend\_commitment on Google Cloud. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. Omit for every provider this organization has. Call `get-identity` if you are unsure which are available. | | `status` | string | none | Restrict to one status, for example active or expired. | ## list-cost-alert-events **Purpose:** What one cost alert has said over time, newest first. **Returns:** one entry per firing and per recovery, with the day it was about, the figure observed, the line it crossed and the cost basis it was measured on. **When to use:** Settling whether a threshold is set well: a rule that fired eleven times last month is set too tight, and this is the only place that is visible. **NOT for:** The current state, which `list-cost-alerts` carries. ### Parameters | Parameter | Type | Default | Description | | ----------- | ---------------- | -------- | ---------------------------------------------- | | `alert_id` | string | required | Cost alert id, a UUID from `list-cost-alerts`. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | ## list-cost-alerts **Purpose:** The organization's cost alerts and whether any is firing right now. **Returns:** each rule with what it watches, its thresholds, where it goes, and the live state of every thing it watches. **When to use:** "Are we over budget", "what are we being alerted on", or before proposing a new alert, so an existing one is changed with `update-cost-alert` rather than duplicated. **NOT for:** What spend actually is, use `get-cost-summary`. ### Parameters Takes no arguments. ## list-recommendations **Purpose:** Savings opportunities LevelFour has found, largest first. **Returns:** a paginated list with id, service, environment, account, monthly savings, the share of current spend that saves, `display_status`, risk level, how many resources it touches (`resource_count`, null when not itemised) and a short description. Only open opportunities unless display\_status says otherwise, and the filter applied is echoed back as `filtered_to`. Read `display_status`: it is the word every LevelFour surface prints for a recommendation. `status` carries the same value here and is kept only for clients written before `display_status` existed. Full detail, annual figures included, is deliberately omitted: open one with `get-recommendation`. With group\_by, returns `totals` per value of that dimension instead of the list, with no pages to walk. A row whose resources carry virtual tags lists them in `virtual_tags`, key name to value. **When to use:** "What can we save." Filter server-side rather than paging and discarding: to answer "what can we save on RDS in staging", pass service and environment. `service` here is spelled the short way, ElastiCache or RDS, not the billing name the cost tools return, so a service copied out of `get-cost-breakdown` ("Amazon ElastiCache", "Amazon Relational Database Service") matches nothing and comes back as an empty page that reads like an empty backlog. When a spelling is in doubt, drop the filter and read `service` off the rows. For "which service holds the most" or any per-team or per-account split, pass group\_by rather than paging the backlog and adding it up. For one team on a virtual tag, pass virtual\_tag\_key and virtual\_tag\_value. **Examples:** biggest open wins: no arguments. Where the savings are concentrated: group\_by="service". Everything actionable without a human: available\_formats=\["automated"]. What has already been applied: display\_status=\["optimized"]. What failed to apply: display\_status=\["failed"]. **NOT for:** Savings already realized, use `get-savings-realized`. These are potential and unbooked, and the two must never be added together. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ----------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `account` | array of string | none | Restrict to these values. Omit for all. | | `available_formats` | array of string | none | Restrict to opportunities applicable this way: automated, iac, or manual. | | `display_status` | array of string | none | Restrict to these statuses. Omitted, only open opportunities are returned. The accepted values are 'available' (open, nobody has taken it), 'pending' (accepted and waiting to be applied), 'awaiting\_approval', 'processing', 'in\_progress' (a rollout under way), 'optimized' (applied, and 'saved' is accepted for the same thing), 'failed' (an apply that did not complete), 'rejected' and 'unavailable' (locked by the plan). 'in\_progress' and 'failed' have no tab on the savings page, so this is the only way to find a rollout that is running or one that broke. Any other value returns zero rows rather than an error, so an empty page can mean a typo here rather than an empty backlog. | | `environment` | array of string | none | Restrict to these values. Omit for all. | | `group_by` | `service`, `environment`, `account`, `tag` | none | Total the savings by this dimension instead of listing them. Returns one row per value with its savings and how many opportunities it holds, largest first. The filters still apply, so the totals cover exactly the rows the same call would have listed. A row flagged unassigned holds the opportunities where that dimension is not set, and its value cannot be passed back as a filter. page, page\_size and sort\_by are ignored when this is set. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `provider` | string | none | Cloud provider id, for example aws, gcp, azure or k8s. This tool reports on one provider at a time: omit it and the organization's first connected provider is used, which is not the whole bill for a multi-cloud organization. Call `get-identity` to see which are available. | | `service` | array of string | none | Restrict to these values. Omit for all. | | `sort_by` | `monthly_savings`, `savings_percentage`, `recommendation_id`, `service`, `status`, `created_at` | `"monthly_savings"` | Field to sort by. | | `tag` | array of string | none | Restrict to these values. Omit for all. | | `virtual_tag_key` | string | none | A virtual tag's name or id. Keeps the opportunities whose resources carry one of virtual\_tag\_value under that key, so it needs virtual\_tag\_value. | | `virtual_tag_value` | array of string | none | Values of virtual\_tag\_key to keep. '**unallocated**' keeps the opportunities whose resources no rule assigned. | ## list-repositories **Purpose:** The organizations and repositories a savings can open its pull request against. **Returns:** called bare, the version control installations this organization has. Called with one, the repositories that installation can reach, narrowed by `query` when given. **When to use:** Before `set-implementation-method` on a format that opens a pull request, to put the orgs and then the repositories in front of the user so they can choose. An organization can have thousands of repositories, so search rather than page: `query` matches any part of the name, and "postgres" or the resource in the recommendation is usually enough. **NOT for:** Connecting an organization that has none, which is `connect-version-control`. Whether a savings already has a repository, which `get-savings-readiness` answers as a yes or no without naming it. ### Parameters | Parameter | Type | Default | Description | | ----------------- | ---------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `installation_id` | integer | none | Version control installation from `list-repositories`. Omit to list the installations. | | `page` | integer, min 1 | `1` | 1-indexed page number. | | `page_size` | integer, 1 to 50 | `10` | Rows per page. | | `query` | string | none | Case-insensitive substring of the repository name, applied when an installation is named. Omit to list them all, which is only workable for a small organization. | ## pause-cost-alert > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Silence a cost alert, or arm one that was silenced. **Returns:** the rule, with who paused it. **When to use:** A rule that is firing on known, accepted spend. Pausing is reversible and the list says who did it. **NOT for:** Removing a rule for good, which `delete-cost-alert` does, taking the rule's history with it. ### Parameters | Parameter | Type | Default | Description | | ---------- | ------- | -------- | ------------------------------------------------- | | `alert_id` | string | required | Cost alert id, a UUID from `list-cost-alerts`. | | `paused` | boolean | `true` | True to silence the alert, false to arm it again. | ## preview-commitment-purchase **Purpose:** What one Savings Plan commitment, or one Reserved Instance or Savings Plan built by hand, would have done over the recent bill, before anything is bought. **Returns:** the commitment replayed day by day over the payer's last complete days of the daily bill: eligible on-demand spend covered and left uncovered, commitment used and left unused, utilization, coverage and monthly savings, net\_savings\_monthly at public prices and net\_savings\_monthly\_at\_your\_rates after the negotiated discount. Also a curve of the same at other commitments, the three sized profiles, and AWS's own figure with the gap to ours. The grain is daily and the source is the billing export: a day averages its hours, so every figure is an upper bound on the hourly truth, and the answer should say so. Nothing is bought or raised. unavailable\_reason says why nothing could be replayed. AWS only. With new\_purchase instead of plan\_type and commitment\_hourly, it quotes that purchase on its own term and payment: basis usage when the buying account's payer already runs the target, replayed day by day in units of what is bought, or full\_use (every hour used, at the public on-demand rate, and at face with no saving claimed where there is none) when nothing runs there yet. It answers upfront, recurring\_monthly, commitment\_monthly and term\_total at list price, the covered, left and unused spend, net\_savings\_monthly, savings\_pct, coverage before and after, utilization, break\_even\_months, over\_automation\_cap (Automated Savings would refuse the term total; Manual Setup would not) and the daily series. **When to use:** "What if we committed $10 an hour to a Compute Savings Plan", checking a size before proposing it, or pricing an offer from `list-commitment-offerings`. **NOT for:** Which size to buy, which `get-commitment-purchase-plan` proposes. Renewing something that is expiring, use `get-commitment-renewal-options`. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ---------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `commitment_hourly` | number | none | Dollars an hour to commit, the unit a Savings Plan is bought in. | | `lookback_days` | `30`, `60` | `60` | Days of daily usage replayed. | | `new_purchase` | string | none | A Reserved Instance or Savings Plan on terms of the user's own, in place of a proposal. kind reserved\_instance names service, region, engine, deployment, offering\_class and tenancy as `list-commitment-offerings` lists them, plus resource and quantity. kind savings\_plan names plan\_type, instance\_family and region (an ec2\_instance plan only) and commitment\_hourly. Both name term\_months, payment\_option, buying\_account\_id and the offering\_id the user chose. | | `payer_account_id` | string | none | The paying account whose organization shares the plan. Omit when there is one. | | `payment_option` | `no_upfront`, `partial_upfront`, `all_upfront` | `"no_upfront"` | Database plans are No Upfront only. | | `plan_type` | `compute`, `database` | none | A proposal's plan type: compute covers EC2, Fargate and Lambda; database covers the database services a Database Savings Plan applies to, generation 7 and newer. Omit it with new\_purchase. | | `term_months` | `12`, `36` | `12` | Database plans are sold for 12 months only. | ## preview-virtual-tag **Purpose:** Try a virtual tag's rules over real spend without saving anything. **Returns:** spend, resource count and sample resources per value the rules would assign, `unallocated_spend` for the cost no rule matched, and `shadowed_spend` for the cost a later config matches but an earlier one already took. The window is at most 31 days. **When to use:** Before `create-virtual-tag`, to check a draft assigns what the user meant, or to answer "what would a tag that does this show". **Examples:** Redshift and BigQuery spend as data: one config whose filter has a rule for aws and gcp with the condition service is Amazon Redshift or BigQuery, assigning the value data. **NOT for:** A tag that already exists, use `get-tag`. Nothing is stored, so a preview never changes a report. ### Parameters | Parameter | Type | Default | Description | | ---------------- | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `can_override` | boolean | `false` | Only matters when the tag shares a provider tag's name. False keeps the provider value on lines that carry it; true lets the configs win and falls back to the provider value. | | `collapsed_keys` | array of string | none | Existing keys whose value this tag takes before any config is tried, in order. Each has a source (origin provider or virtual, and the key), an optional scope of providers and a value\_prefix. Use it to merge spellings such as team and Team into one key. | | `configs` | array of string | none | Configs in precedence order: the first whose filter and time\_frame match a cost line assigns it. A filter holds rules, any of which may match. A rule names its providers (aws, gcp) and conditions that must all hold. A condition has a dimension (account, billing\_account, region, service, category, subcategory, usage\_unit, resource, charge\_type, marketplace, tag, tagged, virtual\_tag), an operator (is, is\_not, contains, not\_contains, starts\_with, ends\_with, flexible\_match, not\_flexible\_match), values, and a key for tag, tagged and virtual\_tag. The assign is one of: type value with a value; type percent with splits of value and pct summing to 100; or type cost\_based with an input\_filter and a source key, splitting by each value's share of that key's spend on the input\_filter lines. | | `end` | string, `YYYY-MM-DD` | none | Last day of the window, YYYY-MM-DD. Omit for yesterday. | | `name` | string | `""` | The tag's name. Naming an existing virtual tag keeps its value ids, so the preview matches what saving it would assign. | | `start` | string, `YYYY-MM-DD` | none | First day of the window, YYYY-MM-DD. Omit for 30 days before end. | ## release-rollout > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Release a rollout a colleague asked for, as the admin answering them. **Returns:** the decision as recorded, and the delivery format it was released with. **When to use:** When the user names a rollout a colleague asked for and says to let it through. This is the release itself, so reach for it once they have said yes rather than to show them what is pending. **NOT for:** Seeing what is waiting, which is `list-approval-requests`. Refusing one, which is `decide-recommendation` with decision='rejected'. Nor accepting a savings nobody has asked to roll out, which is `decide-recommendation` as well. Releasing needs a credential an organization admin authorized; a plain API key is refused. ### Parameters | Parameter | Type | Default | Description | | ----------------------- | -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `implementation_method` | `one-click`, `iac`, `one-click-plus-iac`, `manual` | none | Delivery format to settle before releasing, for a request whose author named none. Omit when they did. Must be one of `available_methods` on `get-recommendation`. | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## set-implementation-method > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Record how an accepted savings should be delivered, and for a pull request, where it lands. **Returns:** the recommendation with the format now settled on it, the repositories it will open against, and whether that format can run yet. **When to use:** When the user has told you which format they want. Only the formats in `available_methods` are accepted, so read those from `get-recommendation` if you do not already have them. 'iac' and 'one-click-plus-iac' open a pull request, so they also need the organization and the repositories the user chose, from `list-repositories`. Ask them; a repository is somebody's infrastructure and the customer is the only one who knows which holds this resource. **NOT for:** Starting the rollout, which is `start-rollout`. Changing the format once a rollout has begun, which is refused here: the steps already recorded belong to the old format. Choosing 'manual' says the customer will apply the change themselves, and `get-manual-instructions` is what they follow next. ### Parameters | Parameter | Type | Default | Description | | ------------------- | -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `installation_id` | integer | none | Organization whose repositories the pull request may open against, as an installation id from `list-repositories`. Required for a format that opens one, alongside `repositories`. Name the one the user chose: it is never inferred, because an organization can have several and only the customer knows which holds this resource. | | `method` | `one-click`, `iac`, `one-click-plus-iac`, `manual` | required | How the change gets delivered. 'one-click' is Automated Savings, which applies it in the customer's cloud through a scoped grant they authorize. 'iac' opens a pull request against a bound repository. 'one-click-plus-iac' does both, applying first so the committed infrastructure does not drift. 'manual' means the customer applies it themselves. Only the formats in `available_methods` on `get-recommendation` are accepted; a savings does not back all four. | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | | `repositories` | array of string | none | Repositories the pull request opens against, as owner/name, from `list-repositories` under the same installation. Required for a format that opens one, and one or many is fine. This is the whole selection and it replaces any earlier one, so pass every repository that should receive a pull request. Search for it with the `query` argument on `list-repositories` rather than paging: an organization can have thousands. | ## start-rollout > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Put an accepted savings into the rollout queue. **Returns:** either that the rollout started, or that an approval request was filed for an admin to release, in `outcome`. Which of the two happens depends on the credential: one an admin authorized starts it, any other files the request the way a team member's click does. A new Reserved Instance or Savings Plan purchase (BUY-) started with Automated Savings then waits up to 24 hours before it is sent to AWS, so an admin can still buy it now or cancel it from the LevelFour dashboard; say so rather than reporting it bought. **When to use:** When the user says to go ahead on a savings that is already accepted and already has its delivery format settled, its destination included. **NOT for:** Accepting it, which is `decide-recommendation` and has to happen first. Not for the manual format either: nothing is queued for a change the customer applies themselves, and `get-manual-instructions` is what they need instead. Nor for a format that cannot run yet: Automated Savings needs a scoped grant the customer launches in their AWS console, and IaC needs a bound repository. Both are refused here and both are finished in the LevelFour dashboard, so pass the refusal on rather than trying another way. A credential an administrator authorized may start a savings it also accepted, which is one click of Approve Savings; any other credential files an approval request for somebody else to answer, and that refusal is not something to work around. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `method` | `one-click`, `iac`, `one-click-plus-iac` | none | Delivery format to settle before starting, when the row does not already carry one. Omit when it does. Must be one of `available_methods` on `get-recommendation`. 'manual' is absent on purpose: a change the customer applies themselves is queued nowhere, so `set-implementation-method` is the whole of it. | | `recommendation_id` | string | required | Recommendation id from `list-recommendations`, for example REC-1234. | ## update-anomaly-status > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Record what the user decided about one detected cost anomaly. **Returns:** the anomaly id and the status now recorded against it. **When to use:** Only when the user has told you, in this conversation, what to do with a specific anomaly. "That spike was our migration" is `planned`. "That was not real" is `dismissed`. "We fixed it" is `resolved`. **NOT for:** Changing any cloud spend. This writes a status on a LevelFour row and nothing else. Dismissing also trains the detector, so do not use it to tidy a list. To read anomalies rather than judge them, use `list-anomalies`. Read `status` in the result rather than assuming your argument was stored verbatim. 'acknowledged', 'planned' and 'resolved' all store `resolved` and differ in the feedback recorded, so the row will not read back as 'acknowledged'. ### Parameters | Parameter | Type | Default | Description | | ------------ | -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `anomaly_id` | string | required | Anomaly id from `list-anomalies`. | | `note` | string | none | Short note recorded with the change. Only stored for status='resolved'. | | `status` | `acknowledged`, `dismissed`, `resolved`, `planned` | required | What to record. 'dismissed' says the spike was not a real anomaly and is negative feedback to the detector. The other three all close the anomaly and differ only in the feedback stored against it: 'acknowledged' confirms it was real, 'planned' says the spend was expected such as a migration or a load test, and 'resolved' closes it with no verdict and takes an optional note. Closing is one-shot, so a second call against an already closed anomaly is refused rather than applied. | ## update-commitment-purchase > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Re-size a purchase nobody has decided yet: a Savings Plan on today's proposal, or a purchase raised on terms of the user's own. It buys nothing. **Returns:** the same fields as `create-commitment-purchase`, with rebound true and the same id. Naming neither profile nor commitment\_hourly re-sizes the pick it was raised on against the latest daily bill. A purchase raised with new\_purchase takes a new\_purchase again: its size, term, payment and offering may change, its target and buying account may not. **When to use:** The user wants another size, term or offering for a purchase already raised, or the proposal moved since it was raised. **NOT for:** A purchase anybody already accepted, rejected or requested, which is refused: reject it with `decide-recommendation` and raise again with `create-commitment-purchase`. ### Parameters | Parameter | Type | Default | Description | | ------------------- | ----------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `commitment_hourly` | number | none | A commitment of the user's own, in dollars an hour, instead of a profile. Rounded down to $0.001, and refused above AWS's own recommendation net of what is already bought. | | `new_purchase` | string | none | A Reserved Instance or Savings Plan on terms of the user's own, in place of a proposal. kind reserved\_instance names service, region, engine, deployment, offering\_class and tenancy as `list-commitment-offerings` lists them, plus resource and quantity. kind savings\_plan names plan\_type, instance\_family and region (an ec2\_instance plan only) and commitment\_hourly. Both name term\_months, payment\_option, buying\_account\_id and the offering\_id the user chose. | | `profile` | `conservative`, `balanced`, `max_savings` | none | A profile from the proposal in `get-commitment-purchase-plan`: conservative, balanced or max\_savings. Omit it when naming commitment\_hourly. | | `recommendation_id` | string | required | The BUY- savings to re-size, as `create-commitment-purchase` returned it. | ## update-cost-alert > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Change a cost alert: its name, what it watches, its rule, or where it goes. **Returns:** the stored rule as it now reads. **When to use:** Raising or lowering a threshold, moving a rule to another account or cost view, or changing who hears about it. Only what is passed changes. Read the rule with `list-cost-alerts` first, because a changed `params` replaces the whole rule. **NOT for:** Silencing a rule, which `pause-cost-alert` does and the list then says who did. ### Parameters | Parameter | Type | Default | Description | | ------------------- | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `alert_id` | string | required | Cost alert id, a UUID from `list-cost-alerts`. | | `name` | string | none | A new name. Leave out to keep the current one. | | `params` | object | none | The whole new rule, in the grammar `create-cost-alert` takes, kind included: changing one threshold means sending the rule back with that one figure changed. Leave out to keep the rule. | | `recipient_emails` | array of string | none | Replaces who is notified, as organization members' addresses; an address outside the organization is refused. Leave out to keep the current recipients; an empty list notifies nobody, which is refused unless the alert posts in a Slack channel. | | `slack_channel_ids` | array of string | none | Replaces the Slack channels this alert posts in, up to 10, such as C0123ABCD. Leave out to keep them; an empty list takes them all off. | | `source_id` | string | none | A new account id or cost view id from `list-cost-alerts`. Leave out to keep watching the same one. A commitment rule watches `aws`. | ## update-virtual-tag > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Replace an existing virtual tag's whole definition. **Returns:** the tag as `get-tag` reads it, with the reprocessing `job`. **When to use:** Only when the user asked, in this conversation, for a tag to be changed. Read it with `get-tag`, edit its definition, and send the whole definition back with the expected\_version it reported. Anything you leave out is cleared rather than kept, because this replaces rather than patches. **NOT for:** Defining a tag that does not exist yet, use `create-virtual-tag`. Provider tags, which are read from the bill and never modified. ### Parameters | Parameter | Type | Default | Description | | ------------------ | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `can_override` | boolean | `false` | Only matters when the tag shares a provider tag's name. False keeps the provider value on lines that carry it; true lets the configs win and falls back to the provider value. | | `collapsed_keys` | array of string | none | Existing keys whose value this tag takes before any config is tried, in order. Each has a source (origin provider or virtual, and the key), an optional scope of providers and a value\_prefix. Use it to merge spellings such as team and Team into one key. | | `configs` | array of string | none | Configs in precedence order: the first whose filter and time\_frame match a cost line assigns it. A filter holds rules, any of which may match. A rule names its providers (aws, gcp) and conditions that must all hold. A condition has a dimension (account, billing\_account, region, service, category, subcategory, usage\_unit, resource, charge\_type, marketplace, tag, tagged, virtual\_tag), an operator (is, is\_not, contains, not\_contains, starts\_with, ends\_with, flexible\_match, not\_flexible\_match), values, and a key for tag, tagged and virtual\_tag. The assign is one of: type value with a value; type percent with splits of value and pct summing to 100; or type cost\_based with an input\_filter and a source key, splitting by each value's share of that key's spend on the input\_filter lines. | | `description` | string | `""` | What the tag is for, shown beside it. | | `effective_from` | string | none | First billing month the tag applies to, YYYY-MM, at most 12 months back. Omit for the current month. | | `expected_version` | integer | required | The tag's rules\_version as read from `get-tag`. Required, so a change made since it was read is never overwritten. | | `name` | string | required | The name or id of the tag to change. | ## update-virtual-tag-config > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Replace one config of a virtual tag, leaving the rest of it alone. **Returns:** the tag as `get-tag` reads it, with the reprocessing `job`. **When to use:** Changing what one value matches. This replaces that one config whole, so send its filter and assign together even when only one of them changes, and the config keeps its place in the order. **NOT for:** Changing the tag's name, description or backfill month, use `update-virtual-tag`. Moving a config, use `update-virtual-tag-config-order`. ### Parameters | Parameter | Type | Default | Description | | ------------------ | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assign` | string | required | What the matched lines are assigned: type value with a value, type percent with splits summing to 100, or type cost\_based with an input\_filter and a source key. Same shape as an assign inside `create-virtual-tag`. | | `expected_version` | integer | required | The tag's rules\_version as read from `get-tag`. Required, so a change made since it was read is never overwritten. | | `filter` | string | required | Which cost lines this config claims. Holds rules, any of which may match. A rule names its providers (aws, gcp) and conditions that must all hold. A condition has a dimension, an operator, values, and a key for tag, tagged and virtual\_tag. Same shape as a filter inside `create-virtual-tag`. | | `key` | string | required | The virtual tag's name or id. | | `position` | integer, min 1 | required | Which config, counting from 1 in the order `get-tag` lists them. Its configs are numbered, so read the tag first and use the number in front of the one you mean. | | `time_frame` | string | none | Optional start and end dates, so the config only claims lines inside them. | ## update-virtual-tag-config-order > **Warning** > > Writes. Needs a `read-write` key, and your client asks before calling it. **Purpose:** Move one config of a virtual tag to another place in the order. **Returns:** the tag as `get-tag` reads it, with the reprocessing `job`. **When to use:** Order is precedence: the first config whose filter matches a line assigns it, so a config sitting below a broader one never fires. Move it above that one. This is also the fix after `create-virtual-tag-config`, which always appends last. **NOT for:** Changing what a config matches, use `update-virtual-tag-config`. ### Parameters | Parameter | Type | Default | Description | | ------------------ | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | | `expected_version` | integer | required | The tag's rules\_version as read from `get-tag`. Required, so a change made since it was read is never overwritten. | | `from_position` | integer, min 1 | required | Which config to move, counting from 1 as `get-tag` numbers them. | | `key` | string | required | The virtual tag's name or id. | | `to_position` | integer, min 1 | required | Where it should sit afterwards, counting from 1. Use 1 for highest precedence. | --- # Reading a result Source: https://docs.levelfour.ai/mcp/results.md Every tool returns JSON, serialized into a text block, not a structured content block. Read it as the tool's **Returns** line describes, and as the shapes below show. The values on this page are invented. The field names, their nesting and their types are the ones the server sends. ## A single result `get-cost-summary` with no arguments returns the current position, per provider, plus the monthly series behind it: ```json { "summary": { "overall_monthly_costs": 48210.55, "overall_monthly_costs_percentage": -12.4, "forecasted_monthly_costs": 61880.0, "forecasted_monthly_costs_percentage": -9.1, "last_month_spend": 55040.12, "last_month_spend_percentage": 6.3, "savings_recommendations": 7420.9, "savings_recommendations_percentage": 15.4, "total_savings": 21360.0, "providers": [ { "provider_id": "aws", "provider_name": "AWS", "monthly_spending": 48210.55, "monthly_spending_percentage": -12.4, "monthly_spending_change": -6829.57, "previous_month_spending": 55040.12, "potential_savings": 7420.9, "potential_savings_percentage": 15.4, "account_count": 12 } ] }, "monthly": { "data_points": [ { "month": "2026-08", "amount": 48210.55 }, { "month": "2026-07", "amount": 55040.12 } ] } } ``` The current month is partial, so `overall_monthly_costs` is a month-to-date figure and the newest point in `data_points` is too. Comparing it against a complete month reports a fall that has not happened. `forecasted_monthly_costs` is the projection for the end of the current month, and it is the only forward-looking figure here. ## Every list shares one envelope Nine tools page, and eight of them close with the same fields. Six put the rows and the envelope at the top level, under `items`: `get-cost-breakdown`, `get-usage-costs`, `list-accounts`, `list-commitments`, `list-recommendations` and `list-repositories`. Two nest it, because they return a summary beside the list. `get-savings-realized` puts both under `breakdown`, and `list-anomalies` under `anomalies`. Read `anomalies.items` and `anomalies.next_page` there, not the top level. `list-cost-alert-events` is the ninth. It takes the same `page` and `page_size` but answers under `events` and carries none of the envelope fields below, so page it until a page comes back short. | Field | Type | Meaning | | --------------- | --------------- | ------------------------------------------------------------------------------- | | `page` | integer | The page you asked for, 1-indexed | | `page_size` | integer | Rows per page, capped at 50 | | `returned` | integer | Rows in this response, after any truncation | | `total` | integer | Rows matching the filter. **Absent** when the underlying service does not count | | `has_next_page` | boolean | Whether another page exists | | `next_page` | integer or null | The page number to ask for next, `null` on the last page | `list-recommendations` adds `filtered_to`, echoing the status filter it applied, and `provider`, naming the provider it reported on: ```json { "total_savings": 7420.9, "total_savings_count": 34, "items": [ { "recommendation_id": "REC-1234", "service": "RDS", "environment": "production", "account": "123456789012", "tag": null, "monthly_savings": 1180.0, "savings_percentage": 22.0, "status": "available", "actions": { "confidence": "HIGH", "description": "Right-size two over-provisioned instances to the class their peak supports" }, "implementation_method": null, "implementation_status": null, "implementation_tags": ["Automated Savings", "IaC"], "risk_assessment": { "level": "MODERATE" }, "created_at": "2026-06-08T14:06:29.086054Z", "linked_to": null } ], "page": 1, "page_size": 10, "returned": 1, "total": 34, "has_next_page": true, "next_page": 2, "filtered_to": ["available"], "provider": "aws" } ``` > **Warning** > > Do not assume `total` is there. A service that does not count its rows omits it, and `has_next_page` then means "this page came back full", which is a guess rather than a fact. Page until `has_next_page` is false rather than computing a page count from `total`. Paging correctly is therefore: call, use `items`, and if `has_next_page` is true call again with `next_page`. Filter in the call instead wherever you can. A narrower question costs one round trip; paging a wide one costs many and can still be truncated. ## Fields that appear only sometimes Four fields are absent from a normal response and appear when something is worth telling you. Build against their absence, because a shape you saw once is not the shape you will always get. | Field | Appears when | What to do | | ------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `hint` | A list came back empty | Read it. It names the filter to widen, or the tool to use instead | | `truncated` | Rows were cut, either capped or trimmed to fit the budget | Treat the result as incomplete. Narrow the question or page with a smaller `page_size` | | `total` | The underlying service counts its rows | Absent means you cannot compute a page count. Page until `has_next_page` is false | | `filtered_to` | On `list-recommendations` | Echoes the status filter applied, including the default. An empty-looking backlog is often this filter rather than an empty backlog | An empty list and a broken tool look identical, which is why `hint` exists: ```json { "items": [], "page": 1, "page_size": 10, "returned": 0, "total": 0, "has_next_page": false, "next_page": null, "filtered_to": ["available"], "hint": "No recommendations match those filters. Drop a filter, or widen display_status: this returns only open opportunities unless asked for more." } ``` ## Not every row in `items` is a record `list-recommendations` returns synthesized group headings alongside real opportunities. A heading carries `linked_to.order` of `"group"` and a `status` of `null`, and it exists to title the rows beneath it. ```json { "recommendation_id": "REC-1200", "linked_to": { "order": "group" }, "status": null } ``` Skip any row whose `linked_to.order` is `"group"` before you count rows, sum `monthly_savings`, or present a list. Do not skip on `linked_to` alone: a row with `"order": "first"` is the parent of a dependent group and is a real recommendation with real savings. ## Truncation is reported in the payload A result that would exceed what one tool call may return does not fail and does not silently shorten. The server drops rows and adds a `truncated` field saying so, which is the only signal that what you are holding is incomplete. ```json { "items": ["..."], "truncated": "Showing 25 of 200 rows in items because the full result exceeded the size a single tool result may return. Ask a narrower question, or page through with a smaller page_size." } ``` A second form appears where a tool caps an unpaginated list instead of trimming one to fit a budget: ```json { "truncated": "Capped at the 200 largest of 4310. Narrow the date range or add a filter rather than asking for more rows." } ``` Two numbers bound this. A page is at most 50 rows, and one serialized result is at most 40,000 characters. The character budget is the one that bites on wide rows, and it is set below the point where a client writes a large tool result to disk and makes the model read it back. > **Tip** > > If you are parsing results in code, test for `truncated` and do not match its text. The wording differs between the two cases above and is not an API contract. ## Errors A tool that fails returns an error result in place of a payload, and the message is written for whoever reads it next. An argument the server can tell you is wrong comes back with the reason, so it can be corrected and retried: ```text Unknown provider 'azrue'. This organization has data for: aws, gcp. ``` An unexpected failure is reported, not narrated, and the detail stays server-side: ```text LevelFour could not complete that request. The failure has been reported. Try a narrower question, or a different tool. ``` Rate limiting happens at the transport, before any tool runs, so it arrives as an HTTP response, not a tool result: ```http HTTP/1.1 429 Too Many Requests Retry-After: 37 Content-Type: application/json { "error": "rate_limit_exceeded", "message": "Too many requests. The limit is 120 per minute. Retry in 37 seconds." } ``` `Retry-After` is in seconds. The limit is 120 requests per minute per credential over a sliding 60 second window, with a second ceiling of 1200 per minute per source address as an abuse backstop. One tool call fans out to several service calls, which is why this ceiling sits below the REST API's. A rejected credential returns `401` with a `WWW-Authenticate` header pointing at the server's protected resource metadata, which is how a client discovers where to authorize. [Connecting a client](/mcp/authentication#troubleshooting) separates the causes. ## Checking the service is up `GET https://mcp.levelfour.ai/health` is unauthenticated and exempt from both rate limits: ```json { "status": "ok", "service": "levelfour-mcp" } ``` > **Warning** > > This says the service is up, not that your connection works. Call `get-identity` for that. ## Next * [Tools](/mcp/tools) is every tool, parameter, enum and default * [Ask it this](/mcp/ask-it-this) has prompts that work as written * [Connecting a client](/mcp/authentication) covers credentials and per-client setup --- # Prompts and resources Source: https://docs.levelfour.ai/mcp/prompts-and-resources.md Alongside its tools the server ships 5 prompts and 4 resources. They carry the two parts of a FinOps answer that tools alone leave to chance: the order the calls go in, and the values the filters accept. ## Prompts A prompt is a workflow the server hands your client. In Claude Code it shows up as a slash command. It returns instructions rather than data, so the model still makes every tool call itself and sees every result. A prompt cannot read your spend and cannot act on its own. The reason they exist: a real question is rarely one call. "Why did the bill go up" is a fixed sequence of seven, with three places a model reliably slips. It reaches for a month-over-month comparison without noticing that the current month is half over. It reports a per-team total without reading the allocation coverage sitting next to it. It adds realized savings to potential savings. Those corrections belong next to the data, versioned with it, instead of in your memory or in a prompt you paste every month. ### monthly\_bill\_review *Review a month's cloud spend: what changed, what drove it, what to do* Takes `month` as `YYYY-MM`, or nothing for the most recently completed month. Seven steps, in order: totals, what grew, the day it started, which account and service, whether it was price or volume, whether an anomaly was already flagged, and what can be done about it. Step 2 pins `compare_to` to the default and tells the model to say how many days each window covers if it deviates. Step 5 uses the quantity column rather than cost, because cost rounds to cents and a rounded zero looks like idleness. It ends by asking for what could not be determined, stated plainly, instead of filled in. ### quarterly\_commitment\_review *Assess commitment coverage and find expiry cliffs before they hit the bill* Takes `quarter_end` as `YYYY-MM-DD`, or nothing for the end of the current quarter. Coverage and utilization are opposite problems, and this one says so where the model will read it. Utilization divides what was used by what was bought; coverage divides covered usage by the whole eligible bill. Full utilization at low coverage is saturation, and the answer there is to buy more rather than to stop. It leads on the effective savings rate, because a blended rate flatters an account that bought nothing, then walks the individual commitments for the instant each one ends, and requires the model to state that the forecast it just pulled knows nothing about the expiries it just found. ### waste\_by\_team *Attribute spend and savings opportunity by team, with the missing join stated* Takes `tag_key`, or nothing to discover it. This one is mostly about which key you have. A virtual key joins, so the prompt lists each team's opportunities through `virtual_tag_key` and reports the ones no rule assigned as their own line. A cost allocation tag read from the bill does not: recommendations carry a service, an environment and an account but no such tag, and a model asked for waste per team will invent the join. There the prompt names the gap and offers two honest substitutes, group by account and state the mapping, or group by environment and call it an environment view, and either way it has to say which it took. ### savings\_review *Work one savings from the case for it to a started rollout, with the handoffs named* Takes `recommendation_id`, or nothing for the largest open opportunity. The three reading prompts end at an answer. This one ends at a change, so most of it is about the two places the change stops. Automated Savings applies through a scoped grant you launch as a CloudFormation stack in your own AWS console, and LevelFour cannot launch it for you. The IaC format opens a pull request against a repository you bind through a version control connection. The prompt states both up front so the model treats the refusal as the end of the road rather than something to route around. It branches, because the three delivery formats stop on different things. Manual needs only the guide, which `get-manual-instructions` serves. IaC needs a repository, and the user chooses it: `list-repositories` shows the organizations and then the repositories in one, and `set-implementation-method` settles the format together with the ones they picked. `connect-version-control` comes first when the organization has no GitHub connection at all. Automated needs an access grant, which `create-savings-grant` mints and the customer authorizes by launching a stack in their own AWS console. It also tells the model to read what `start-rollout` returns instead of assuming it worked: a credential an admin authorized starts the rollout, and any other files an approval request instead. And it forbids polling. A rollout can take 12 to 72 hours to begin, so the answer to "is it done" is another call tomorrow. Whether the savings was actually applied is settled elsewhere and reported back, so the prompt says outright not to declare it done because the steps were followed. ### approvals\_review *Work the queue of rollouts waiting on an admin, one at a time* Takes nothing. The maker/checker queue: a teammate asks for a savings to be rolled out, an admin releases it. The prompt reads the queue, then insists on opening each recommendation before answering it, because approving a request without reading what it does is the failure mode. Where the requester named no delivery format, releasing refuses until one is chosen, and the prompt says so rather than letting the model discover it. One instruction in it is a refusal of its own: it will not release a batch of these on a single "approve them all". Each one changes something in your cloud. ### Running one In Claude Code, type `/` and look for the server's prompts: ```text /mcp__levelfour__monthly_bill_review 2026-07 /mcp__levelfour__quarterly_commitment_review /mcp__levelfour__waste_by_team team /mcp__levelfour__savings_review REC-1234 /mcp__levelfour__approvals_review ``` Arguments go after the command, space separated. Other clients surface prompts through their own menus, and some do not surface them at all yet. ## Resources A resource is a document your client can pull into context, addressed by URI. These answer the question a model otherwise guesses at: what values does this filter accept? Guessing costs you calls. A model that assumes your services are named `AmazonEC2` and `AmazonRDS` spends round trips finding out that your data writes them differently, and sometimes reads the resulting empty result as a real zero. Loading the vocabulary once fixes the whole session. | URI | What it holds | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `levelfour://providers` | The cloud providers your organization has cost data for. Read it before passing any `provider` argument. | | `levelfour://filters/costs` | Services, accounts, regions, tags and the virtual tag keys your own rules define, as they appear in your cost data. The dimensions cross-constrain each other the way Cost Explorer's do. | | `levelfour://filters/recommendations` | Services, environments, accounts and tags that appear on your recommendations. These are the values `list-recommendations` filters on. | | `levelfour://filters/commitments` | Values the commitment filters accept. | They all return `application/json` and are computed per organization from your own data, which is why `provider` is a plain string in the tool schemas rather than a frozen enum. A static enum was wrong in both directions at once: it listed providers a tenant did not have, and omitted ones it did. In Claude Code, reference one with an `@` mention: ```text Using @levelfour:levelfour://filters/recommendations, what can we save on RDS in staging? ``` ## Next * [Tools](/mcp/tools) is the generated reference, with every parameter, enum and default * [Reading a result](/mcp/results) is the shape of what the calls return * [Ask it this](/mcp/ask-it-this) has prompts that work as written, and the questions that do not --- # Ask it this Source: https://docs.levelfour.ai/mcp/ask-it-this.md Every prompt below is written to be copied as it stands. Each one says which tools it lands on and the mistake it is worded to avoid, because the difference between a useful answer and a confident wrong one is usually a detail the model was never told to check. Substitute your own service names, months and ids. Read `levelfour://filters/costs` first if you want the values your data actually contains. ## Start here ```text Using LevelFour, what did we spend in July 2026, and how did that compare with June? ``` Lands on `get-cost-summary` with `period="2026-07"`. One completed month and its change on the month before, which is the comparison that is safe to read as a number. > **Warning** > > Today's month is partial. A model that compares a four-day month against a full one will report a collapse in spend. Say "July" rather than "this month" when you want a number to hold still. > **Warning** > > Read the quantity column, not the cost column, when you are judging whether something is idle. Cost rounds to cents, and a rounded zero looks exactly like a resource nobody is using. ## By question ### What are we spending, and where will we land? ```text Using LevelFour, what are we spending on cloud this month, how does that compare with last month, and where are we forecast to land? ``` Lands on `get-cost-summary`, then `get-cost-forecast`. You get totals, the per-provider split, the month-over-month direction and a projection for the end of the current month with its confidence range. The prompt asks for the direction rather than the delta because the current month is partial. ```text Using LevelFour, break down last month's spend by service, then by account, and show me the ten largest of each. ``` Lands on `get-cost-breakdown` twice. Pass `period` or a `start` and `end`, and sort by cost, service, region, account or change. ### Why did the bill move? ```text Our AWS bill went up. Using LevelFour, tell me which services grew the most in dollars between the last two comparable periods, then for the largest one find the day the change started and tell me whether it was a price change or a volume change. ``` Lands on `get-cost-growth`, then `get-cost-series`, then `get-usage-costs`. Growth returns both windows and how many days each covers, so a partial month shows up rather than hiding. The series dates the step. Usage costs separate the two causes. ```text Using LevelFour, did anything spike in July 2026? List the anomalies with their service, account and impact, and say plainly if there were none. ``` Lands on `list-anomalies` with `start` and `end`. Detections only. Steady growth never registers as an anomaly, which is what `get-cost-growth` is for, and asking for "none" in words is what stops an empty result being read as an outage. ### What can we save? ```text Using LevelFour, how much could we save in total, and what are the ten largest open opportunities? Give me id, service, account, monthly saving and risk level for each. ``` Lands on `get-savings-potential`, then `list-recommendations`. The first sizes the backlog, the second lists it largest first. Only open opportunities come back unless you ask otherwise. ```text Using LevelFour, what can we save on RDS in staging? ``` Lands on `list-recommendations` with `service` and `environment`. Filter in the call. A model that pulls the whole backlog and throws most of it away spends your page budget and your time getting to the same answer. ```text Using LevelFour, which recommendations can be applied without a human touching anything? ``` Lands on `list-recommendations` with `available_formats=["automated"]`. ```text Using LevelFour, walk me through REC-1234: what changes, what it saves per month, what the risk is, and what could go wrong. ``` Lands on `get-recommendation`. Financials, affected resources, risk assessment, metrics, the implementation method and the date the figures are as of. ```text Using LevelFour, what have we already applied and what did it actually save? ``` Lands on `get-savings-realized`. Booked savings with ROI, payback and a line-item history. For the recommendations behind them, `list-recommendations` with `display_status=["optimized"]`. ### How are our commitments doing? ```text Using LevelFour, how well are our Reserved Instances and Savings Plans covering us, and what expires in the next 90 days? For each expiry, tell me what reverts to on-demand. ``` Lands on `get-commitment-overview`, then `get-commitment-coverage` and `list-commitments`. Coverage and utilization are opposite problems because they divide by different things: coverage against the whole eligible bill, utilization against what was purchased. Paying on-demand for something a commitment could cover, and paying for capacity nobody used, are separate mistakes and a blended figure hides both, which is why each has its own tool. End dates live in `list-commitments`, and `get-commitment-renewal-plan` sizes the replacement, which is worth asking a month early: a replacement bills from the moment it is bought, so buying early pays twice and buying late leaks. ### What does each team and account cost? ```text Using LevelFour, which cost allocation tag keys do we have? ``` Lands on `get-costs-by-tag` with no arguments. Ask this before asking for a team split, because the key you assume exists usually does not. ```text Using LevelFour, what does each team cost, using the team tag? Tell me the allocation coverage alongside it. ``` Lands on `get-costs-by-tag` with `tag_key`. The coverage figure is the point of the second sentence: if half your spend carries no tag, every per-team number is a share of the tagged half, and an answer that omits that is misleading rather than approximate. ```text Using LevelFour, which cloud accounts are connected, and is billing data flowing for all of them? ``` Lands on `list-accounts`. Also how you turn an account id in a cost row into a name. ## Longer analyses The five prompts run a full ordered workflow, not a single call. In Claude Code: ```text /mcp__levelfour__monthly_bill_review 2026-07 /mcp__levelfour__quarterly_commitment_review /mcp__levelfour__waste_by_team team /mcp__levelfour__savings_review REC-1234 /mcp__levelfour__approvals_review ``` See [Prompts and resources](/mcp/prompts-and-resources). ## What it cannot answer ### Are we going to blow the budget? LevelFour has no budget object. Nothing here stores a target, a threshold or an alert, and a forecast on its own cannot tell you whether a number is too high. Give the model the figure and it can compare against `get-cost-forecast`. A model that answers this without asking you for the number invented a baseline from past spend. ### Where will we land next quarter? The forecast covers the end of the current month. Nothing projects further, and a model asked for a quarter will extrapolate a run rate and present it as a forecast. ### How much is each team wasting? It depends which kind of key names your teams. A virtual key joins: every opportunity carries the values its rules assigned, and `list-recommendations` takes `virtual_tag_key` and `virtual_tag_value` to list one team's backlog. A cost allocation tag read from the bill does not, because recommendations carry a service, an environment and an account but no such tag. The `waste_by_team` prompt picks the join when there is one and otherwise groups opportunities by account or by environment and says which it did. Any answer that puts a waste figure against a team name without naming that substitution is guessing. ### Apply REC-1234 for me. On a connection you approved for writing, it can take this most of the way. `decide-recommendation` marks REC-1234 accepted and stamps you as the approver, `set-implementation-method` settles how the change is delivered, and `start-rollout` puts it in the rollout queue. What it cannot do is the part that needs your browser: Automated Savings applies through a CloudFormation stack you launch in your own AWS console, and the IaC format needs a repository bound through a version control connection. The tools refuse by naming whichever one is missing. `savings_review` walks the whole sequence. ### What have we saved in total, potential and realized? Two different quantities, and adding them double counts. Potential savings are unbooked opportunities. Realized savings are applied and measured. Ask for them separately. ### What did we save in Q2? `get-savings-realized` sums a ledger where each row carries a full monthly rate stamped in its apply month. Summing rows gives a monthly run rate, not a total for a period. Multiply the run rate by twelve for an annual figure, and do not read the sum as cash saved inside that window. ### Which EC2 instance is the most expensive? Cost rows group by service, region and account, not by individual resource. `get-usage-costs` reaches usage-type granularity. Below that, a recommendation names its own affected resources, and the rest lives in the dashboard. ### Show me all 400 recommendations. Every list caps at 50 rows a page, and one result is capped past which the server drops rows and says so. Ask a narrower question rather than paging forty times. [Reading a result](/mcp/results#truncation-is-reported-in-the-payload) has the numbers. > **Tip** > > A model chooses its own tools, so the same question asked twice can take a different route. Expand the tool call in your client to see which ran and with what arguments. That is where the two most common wrong answers are visible: an empty result read as a real zero, and a month-over-month comparison run on a month that is four days old. [Reading a result](/mcp/results) covers the fields that qualify an answer. ## Next * [Reading a result](/mcp/results) is what comes back: the pagination envelope, truncation, limits and errors * [Tools](/mcp/tools) is the generated reference, with every parameter, enum and default * [CLI](/cli) has the write commands this server does not expose --- # API Reference Source: https://docs.levelfour.ai/api-reference.md The base URL, the authorization header and the response envelope are the same for every endpoint below. ## Base URL ```text https://api.levelfour.ai ``` > **Note** > > All endpoints require a Bearer token in the `Authorization` header: `Authorization: Bearer l4_live_your_key_here`. See [Authentication](/authentication). ## Endpoints | Category | Covers | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | [Auth](/api-reference/auth) | Confirm which organization, plan and role a key resolves to | | [Providers](/api-reference/providers) | Per-provider data for recommendations, costs, and savings | | [Costs](/api-reference/costs) | Spend summaries, breakdowns, trends, usage, allocation and forecasts | | [Google Cloud](/api-reference/google-cloud) | Google Cloud inventory, utilization, budgets, access and data availability | | [Anthropic](/api-reference/anthropic) | Claude Code and Cowork usage, activity and cost from a connected Anthropic organization | | [Tags](/api-reference/tags) | Provider cost allocation tags read from the bill, and virtual tags LevelFour assigns from ordered rules | | [Cost Views](/api-reference/cost-views) | Saved cost views: a named set of filters you can read back and reuse | | [Recommendations](/api-reference/recommendations) | List savings recommendations, choose resources, give rejection feedback and request execution | | [Savings Grants](/api-reference/savings-grants) | Scoped, time-limited access that lets LevelFour apply a saving for you | | [Repo Binding](/api-reference/repo-binding) | Choose the repositories an infrastructure-as-code saving opens its pull request in | | [Audit](/api-reference/audit) | Realized savings from completed recommendations | | [Commitments](/api-reference/commitments) | Reserved Instances and Savings Plans: inventory, utilization, coverage, renewals and purchase plans | | [Anomalies](/api-reference/anomalies) | Cost anomalies, their detail, and your notification preference | | [Cost Alerts](/api-reference/cost-alerts) | Budget, spend change and commitment alerts: create, change, pause and delete them, and read what they said | | [Accounts](/api-reference/accounts) | Connected cloud accounts, enabled modules, and work-tool connections | | [Integrations](/api-reference/integrations) | Read a connected work tool and file tasks in it, such as a Jira issue from a saving | | [Webhooks](/api-reference/webhooks) | Subscribe to event notifications | ## Response format Every response arrives in the same envelope. ```json { "success": true, "data": { ... }, "timestamp": "2025-10-15T10:00:00Z" } ``` An error sets `success` to `false` and replaces `data` with `error`. ```json { "success": false, "error": { "code": "NOT_FOUND", "message": "Resource not found", "details": null }, "timestamp": "2025-10-15T10:00:00Z" } ``` ## Next * [API Quickstart](/api-quickstart) installs an SDK, points it at a key, and makes the first call * [Authentication](/authentication) is the key formats, what each scope reaches, and where an SDK looks for a key * [Error Handling](/error-handling) is the full list of error codes and the class each one raises in every SDK * [Pagination](/pagination) is what list endpoints add inside `data`, and how to walk it * [Resources](/resources) is the same surface as method calls in the Python, TypeScript and Go SDKs --- # Auth Source: https://docs.levelfour.ai/api-reference/auth.md - [Get Whoami](/api-reference/auth/get-auth-whoami) --- # Get Whoami Source: https://docs.levelfour.ai/api-reference/auth/get-auth-whoami.md ## GET /api/v1/auth/whoami ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `organization` (string, required) - `plan` (string, required) - `role` (string, required) - `accounts` (array of object) - `name` (string, required) - `provider` (string, required) - `account_id` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Providers Source: https://docs.levelfour.ai/api-reference/providers.md - [List connected cloud providers](/api-reference/providers/get-providers) - [Get provider-specific recommendations summary](/api-reference/providers/get-providers-by-id-recommendations-summary): Returns top 7 recommendations for a specific cloud provider - [Get provider-specific recommendations breakdown](/api-reference/providers/get-providers-by-id-recommendations): Returns paginated breakdown of recommendations for a specific provider - [Get provider recommendations overview](/api-reference/providers/get-providers-by-id-recommendations-overview): Returns overview metrics (total spend, available/pending savings, saved ITD) for a provider - [Get provider top savers](/api-reference/providers/get-providers-by-id-recommendations-top-savers): Returns the top users by realized (captured) savings for a provider, ranked descending (max 3) - [Get provider recommendation filter options](/api-reference/providers/get-providers-by-id-recommendations-filter-options): Returns distinct environments, accounts, and tags for recommendation filtering - [Get Savings Audit breakdown by provider](/api-reference/providers/get-providers-by-id-recommendations-audit-breakdown): Returns saved recommendations for a provider as paginated JSON (format=table) or a CSV download of the full filtered set (format=csv) - [Get provider-specific Savings Audit summary](/api-reference/providers/get-providers-by-id-recommendations-audit-summary): Returns saved summary totals for a specific provider (for BigNumbers) - [Get provider spending summary](/api-reference/providers/get-providers-by-id-costs-summary): Returns spending summary for a specific cloud provider - [Get provider cost dimensions](/api-reference/providers/get-providers-by-id-costs-dimensions): The groupings, filters and cost metrics the provider's cost breakdown answers, each under the name the provider gives it. The dashboard builds the Cost Breakdown's menus from this. - [Get provider spending filter options](/api-reference/providers/get-providers-by-id-costs-filter-options): Returns available values for every filter dimension. Dimensions cross-constrain each other (AWS Cost Explorer behavior); each dimension excludes its own selections when computing its option list. - [Get provider service breakdown](/api-reference/providers/get-providers-by-id-costs-breakdown): Returns service breakdown in different formats: table (paginated), chart (aggregated), csv (export), raw (all records) - [Get provider spending timeline](/api-reference/providers/get-providers-by-id-costs-timeline): Returns spending timeline for a specific provider - [Get provider invoice months](/api-reference/providers/get-providers-by-id-costs-invoices): Returns spend per invoice month split by cost type (regular, tax, adjustment, rounding). Google Cloud only; other providers return no months. - [Get provider potential savings summary](/api-reference/providers/get-providers-by-id-potential-savings-summary): Returns potential savings summary for a specific provider - [Get provider potential savings breakdown](/api-reference/providers/get-providers-by-id-potential-savings-breakdown): Returns paginated potential savings breakdown for a specific provider --- # Get provider service breakdown Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-costs-breakdown.md ## GET /api/v1/providers/{provider_id}/costs/breakdown Returns service breakdown in different formats: table (paginated), chart (aggregated), csv (export), raw (all records) ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `format` (query, string, default `table`): Response format: table (paginated UI), chart (aggregated stacked bar), csv (export), raw (all records) - `start` (query, string, nullable): Start date in ISO 8601 format (e.g., 2026-02-01T00:00:00.000Z). Defaults to first of current month - `end` (query, string, nullable): End date in ISO 8601 format (e.g., 2026-02-01T00:00:00.000Z). Defaults to today - `granularity` (query, string, default `daily`): Granularity: 'daily' or 'monthly' (used for chart format) - `page` (query, integer, default `1`): Page number (1-indexed, table format only) - `page_size` (query, integer, default `10`): Items per page (max 100, table format only) - `sort_by` (query, string, default `cost`): Field to sort by (service, environment, region, account_id, cost, previous_cost, change_percentage) - `sort_order` (query, one of `asc`, `desc`, default `desc`): Sort order - `sort_by_date` (query, string, nullable): Sort by a specific date's cost (YYYY-MM-DD for daily, YYYY-MM for monthly). Overrides sort_by. - `preset` (query, string, nullable): Date preset: '30D', '6M', or '12M' (takes precedence over start/end) - `service` (query, array of string, nullable): Filter by service(s) - can be repeated for multiple values - `service_mode` (query, one of `include`, `exclude`, default `include`) - `environment` (query, array of string, nullable): Filter by environment(s) - can be repeated for multiple values - `account_id` (query, array of string, nullable): Filter by account ID(s) - can be repeated for multiple values - `account_id_mode` (query, one of `include`, `exclude`, default `include`) - `region` (query, array of string, nullable): Filter by region(s) - can be repeated for multiple values - `region_mode` (query, one of `include`, `exclude`, default `include`) - `tag_key` (query, array of string, nullable): Filter by tag key(s) - required when group_by=tag - `tag_key_mode` (query, one of `include`, `exclude`, default `include`) - `tag_value` (query, array of string, nullable): Filter by tag value(s) - can be repeated for multiple values - `tag_value_mode` (query, one of `include`, `exclude`, default `include`) - `virtual_tag_key` (query, array of string, nullable): Virtual tag key, by id or name (case-insensitive). Required when group_by=virtual_tag. One key per request - `virtual_tag_value` (query, array of string, nullable): Filter by value(s) of virtual_tag_key. __unallocated__ matches cost no config matched - `virtual_tag_value_mode` (query, one of `include`, `exclude`, default `include`) - `group_by` (query, array of string, nullable): Group by field(s), for example service, account_id, region, tag or virtual_tag - `show_cost_as` (query, string, default `unblended`): Cost metric: unblended, blended, amortized, net_unblended, net_amortized, or usage_value (usage priced at list price, an estimate, for the providers that declare it) - `instance_type` (query, array of string, nullable): Filter by instance type(s) - `instance_type_mode` (query, one of `include`, `exclude`, default `include`) - `usage_type` (query, array of string, nullable): Filter by usage type(s) - `usage_type_mode` (query, one of `include`, `exclude`, default `include`) - `usage_type_group` (query, array of string, nullable): Filter by usage type group(s) - `usage_type_group_mode` (query, one of `include`, `exclude`, default `include`) - `charge_type` (query, array of string, nullable): Filter by charge type(s) - `charge_type_mode` (query, one of `include`, `exclude`, default `include`) - `availability_zone` (query, array of string, nullable): Filter by availability zone(s) - `availability_zone_mode` (query, one of `include`, `exclude`, default `include`) - `platform` (query, array of string, nullable): Filter by platform(s) - `platform_mode` (query, one of `include`, `exclude`, default `include`) - `purchase_option` (query, array of string, nullable): Filter by purchase option(s) - `purchase_option_mode` (query, one of `include`, `exclude`, default `include`) - `tenancy` (query, array of string, nullable): Filter by tenancy type(s) - `tenancy_mode` (query, one of `include`, `exclude`, default `include`) - `database_engine` (query, array of string, nullable): Filter by database engine(s) - `database_engine_mode` (query, one of `include`, `exclude`, default `include`) - `legal_entity` (query, array of string, nullable): Filter by legal entity - `legal_entity_mode` (query, one of `include`, `exclude`, default `include`) - `billing_entity` (query, array of string, nullable): Filter by billing entity - `billing_entity_mode` (query, one of `include`, `exclude`, default `include`) - `api_operation` (query, array of string, nullable): Filter by API operation(s) - `api_operation_mode` (query, one of `include`, `exclude`, default `include`) - `resource` (query, array of string, nullable): Filter by resource ID(s) - `resource_mode` (query, one of `include`, `exclude`, default `include`) - `cost_category_key` (query, array of string, nullable): Filter by cost category key(s) - `cost_category_key_mode` (query, one of `include`, `exclude`, default `include`) - `cost_category_value` (query, array of string, nullable): Filter by cost category value(s) - `cost_category_value_mode` (query, one of `include`, `exclude`, default `include`) - `payer_account` (query, array of string, nullable): Filter by payer account ID(s) - `payer_account_mode` (query, one of `include`, `exclude`, default `include`) - `project` (query, array of string, nullable): Filter by project(s), for a connector that collects an inventory. Table format only - `project_mode` (query, one of `include`, `exclude`, default `include`) - `sku` (query, array of string, nullable): Filter by billed SKU(s), for a connector provider. Table format only - `sku_mode` (query, one of `include`, `exclude`, default `include`) - `search` (query, string, nullable): Case-insensitive substring match across service, account id and name, region, tag key/value, resource id, and usage type. Applies to the table format only. - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - Variant 1: ProviderServiceBreakdownResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider service breakdown data with pagination - `provider_id` (string, required): Provider identifier - `provider_name` (string, required): Display name - `period` (string, required): Period in YYYY-MM format (legacy, use start_date/end_date) - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `total_period_cost` (number, required): Total cost for the period - `items` (array of object, required): Service breakdown items - `service` (string, nullable): Service name (None if grouped by other fields) - `environment` (string, nullable): Environment - `region` (string, nullable): Cloud region - `account_id` (string, nullable): Cloud account ID - `tag_key` (string, nullable): Tag key - `tag_value` (string, nullable): Tag value - `resource` (string, nullable): Resource id (set when grouped by resource) - `sku` (string, nullable): Billed SKU (set when grouped by sku, connector providers only) - `project` (string, nullable): Project (set when grouped by project, DigitalOcean only) - `usage_type` (string, nullable): Usage type (set when grouped by usage_type) - `cost_category` (string, nullable): Cost category value (set when grouped by cost_category) - `purchase_type` (string, nullable): Purchase type (set when grouped by purchase_type) - `instance_type` (string, nullable): Instance type (set when grouped by instance_type) - `charge_type` (string, nullable): Charge type (set when grouped by charge_type) - `virtual_tag` (string, nullable): Virtual tag value (set when grouped by virtual_tag). __unallocated__ is cost no config matched - `cost` (number, required): Current period cost - `previous_cost` (number, nullable): Previous period cost - `change_percentage` (number, nullable): Change from previous period - `spendings_by_date` (array of object, nullable): Daily spending breakdown for this service - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page - `totals_by_date` (array of object, nullable): Per-date totals aggregated across all items (all pages) - `date` (string, required): Date in YYYY-MM-DD format - `value` (number, required): Cost for this date - `change_percentage` (number, nullable): Change from previous day - Variant 2: ProviderServiceCostsBreakdownResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider costs breakdown chart data for stacked bar visualization, grouped by service/account/region/tag - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider_name` (string, required): Display name - `granularity` (string, required): Granularity: 'daily' or 'monthly' - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `groups` (array of string, required): Ordered list of group keys: ['RDS', 'EC2', 'other'] - `data_points` (array of object, required): Time series data points - `date` (string, required): Date in YYYY-MM-DD (daily) or YYYY-MM (monthly) format - `groups` (map of string to number, required): Group costs: {'RDS': 185.30, 'EC2': 245.50, 'other': 128.65} - `total` (number, required): Total cost for this date - `totals` (map of string to number, required): Total per group for the period - `group_labels` (map of string to string): Display name per group key, for keys that are ids rather than names, such as a Google Cloud project. Keys without a name on file are left out. - Variant 3: ProviderServiceBreakdownRawResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider service breakdown raw data without pagination - `provider_id` (string, required): Provider identifier - `provider_name` (string, required): Display name - `period` (string, required): Period in YYYY-MM format - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `total_period_cost` (number, required): Total cost for the period - `items` (array of object, required): All service breakdown items (no pagination) - `service` (string, nullable): Service name (None if grouped by other fields) - `environment` (string, nullable): Environment - `region` (string, nullable): Cloud region - `account_id` (string, nullable): Cloud account ID - `tag_key` (string, nullable): Tag key - `tag_value` (string, nullable): Tag value - `resource` (string, nullable): Resource id (set when grouped by resource) - `sku` (string, nullable): Billed SKU (set when grouped by sku, connector providers only) - `project` (string, nullable): Project (set when grouped by project, DigitalOcean only) - `usage_type` (string, nullable): Usage type (set when grouped by usage_type) - `cost_category` (string, nullable): Cost category value (set when grouped by cost_category) - `purchase_type` (string, nullable): Purchase type (set when grouped by purchase_type) - `instance_type` (string, nullable): Instance type (set when grouped by instance_type) - `charge_type` (string, nullable): Charge type (set when grouped by charge_type) - `virtual_tag` (string, nullable): Virtual tag value (set when grouped by virtual_tag). __unallocated__ is cost no config matched - `cost` (number, required): Current period cost - `previous_cost` (number, nullable): Previous period cost - `change_percentage` (number, nullable): Change from previous period - `spendings_by_date` (array of object, nullable): Daily spending breakdown for this service - `total_items` (integer, required): Total number of items #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider cost dimensions Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-costs-dimensions.md ## GET /api/v1/providers/{provider_id}/costs/dimensions The groupings, filters and cost metrics the provider's cost breakdown answers, each under the name the provider gives it. The dashboard builds the Cost Breakdown's menus from this. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `provider_id` (string, required) - `group_by` (array of object, required) - `key` (string, required): The group_by value the breakdown accepts - `label` (string, required): What the provider calls this dimension - `table` (boolean, required): Whether the breakdown table groups by it - `chart` (boolean, required): Whether the breakdown chart groups by it - `filters` (array of object, required) - `key` (string, required): The breakdown query parameter - `label` (string, required): What the provider calls this dimension - `options_key` (string, required): The filter-options field that lists its values - `cost_metrics` (array of object, required): The show_cost_as values the provider answers; one means there is no choice to offer - `key` (string, required): The show_cost_as value - `label` (string, required) - `credits_basis` (boolean, required): Whether the before/after credits toggle applies #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider spending filter options Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-costs-filter-options.md ## GET /api/v1/providers/{provider_id}/costs/filter-options Returns available values for every filter dimension. Dimensions cross-constrain each other (AWS Cost Explorer behavior); each dimension excludes its own selections when computing its option list. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `start` (query, string, nullable): Start date in ISO 8601 format - `end` (query, string, nullable): End date in ISO 8601 format - `service` (query, array of string, nullable) - `service_mode` (query, one of `include`, `exclude`, default `include`) - `region` (query, array of string, nullable) - `region_mode` (query, one of `include`, `exclude`, default `include`) - `account_id` (query, array of string, nullable) - `account_id_mode` (query, one of `include`, `exclude`, default `include`) - `payer_account` (query, array of string, nullable) - `payer_account_mode` (query, one of `include`, `exclude`, default `include`) - `tag_key` (query, array of string, nullable) - `tag_key_mode` (query, one of `include`, `exclude`, default `include`) - `tag_value` (query, array of string, nullable) - `tag_value_mode` (query, one of `include`, `exclude`, default `include`) - `instance_type` (query, array of string, nullable) - `instance_type_mode` (query, one of `include`, `exclude`, default `include`) - `usage_type` (query, array of string, nullable) - `usage_type_mode` (query, one of `include`, `exclude`, default `include`) - `usage_type_group` (query, array of string, nullable) - `usage_type_group_mode` (query, one of `include`, `exclude`, default `include`) - `charge_type` (query, array of string, nullable) - `charge_type_mode` (query, one of `include`, `exclude`, default `include`) - `availability_zone` (query, array of string, nullable) - `availability_zone_mode` (query, one of `include`, `exclude`, default `include`) - `platform` (query, array of string, nullable) - `platform_mode` (query, one of `include`, `exclude`, default `include`) - `purchase_option` (query, array of string, nullable) - `purchase_option_mode` (query, one of `include`, `exclude`, default `include`) - `tenancy` (query, array of string, nullable) - `tenancy_mode` (query, one of `include`, `exclude`, default `include`) - `database_engine` (query, array of string, nullable) - `database_engine_mode` (query, one of `include`, `exclude`, default `include`) - `legal_entity` (query, array of string, nullable) - `legal_entity_mode` (query, one of `include`, `exclude`, default `include`) - `billing_entity` (query, array of string, nullable) - `billing_entity_mode` (query, one of `include`, `exclude`, default `include`) - `api_operation` (query, array of string, nullable) - `api_operation_mode` (query, one of `include`, `exclude`, default `include`) - `resource` (query, array of string, nullable) - `resource_mode` (query, one of `include`, `exclude`, default `include`) - `cost_category_key` (query, array of string, nullable) - `cost_category_key_mode` (query, one of `include`, `exclude`, default `include`) - `cost_category_value` (query, array of string, nullable) - `cost_category_value_mode` (query, one of `include`, `exclude`, default `include`) - `virtual_tag_key` (query, array of string, nullable): Virtual tag key(s), by id or name, whose values fill virtual_tag_values ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `provider_id` (string, required): Provider identifier - `services` (array of string, required): Distinct service names available for filtering - `regions` (array of string, required): Distinct regions available for filtering - `accounts` (array of string, required): Distinct account IDs available for filtering - `account_names` (map of string to string): Display name per account id, for connector providers that bill an account under a name - `projects` (array of string): Projects in the window, for a connector that collects an inventory - `skus` (array of string): Billed SKUs in the window, for a connector provider - `tag_keys` (array of string) - `tag_values` (array of string) - `instance_types` (array of string) - `usage_types` (array of string) - `usage_type_groups` (array of string) - `charge_types` (array of string) - `availability_zones` (array of string) - `platforms` (array of string) - `purchase_options` (array of string) - `tenancies` (array of string) - `database_engines` (array of string) - `legal_entities` (array of string) - `billing_entities` (array of string) - `api_operations` (array of string) - `resources` (array of string) - `cost_category_keys` (array of string) - `cost_category_values` (array of string) - `payer_accounts` (array of string) - `virtual_tag_keys` (array of object): Every virtual tag key - `id` (string, required): Virtual tag key id - `name` (string, required): Virtual tag key name - `virtual_tag_values` (array of string): Values of the keys passed as virtual_tag_key, followed by __unallocated__ #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider invoice months Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-costs-invoices.md ## GET /api/v1/providers/{provider_id}/costs/invoices Returns spend per invoice month split by cost type (regular, tax, adjustment, rounding). Google Cloud only; other providers return no months. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `months` (query, integer, default `6`): How many invoice months to return, newest first - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required): Invoice months, newest first - `invoice_month` (string, required): Invoice month, YYYYMM - `total` (number, required): Total for the month - `by_cost_type` (object, required): Total split by cost type - `regular` (number, required): Usage and fees - `tax` (number, required): Taxes - `adjustment` (number, required): Billing adjustments - `rounding_error` (number, required): Rounding corrections - `marketplace_total` (number, required): Charges sold by a third party through the marketplace - `includes_billing_account_charges` (boolean, required): True when lines billed to the account rather than a project (tax, rounding) are included - `credits` (one of `before`, `after`, required): Cost basis applied #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider spending summary Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-costs-summary.md ## GET /api/v1/providers/{provider_id}/costs/summary Returns spending summary for a specific cloud provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider-specific spending summary - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider_name` (string, required): Display name - `monthly_spending` (number, required): Current month spending - `monthly_spending_percentage` (number, nullable): MoM change percentage - `forecasted_monthly_costs` (number, required): Projected full month costs based on daily run rate - `forecasted_monthly_costs_percentage` (number, nullable): Forecast vs previous month actual percentage - `potential_savings` (number, required): Total potential savings for this provider - `potential_savings_percentage` (number, required): Savings as percentage of spending - `top_services` (array of object, required): Top 3 services by cost - `service` (string, required): Service name - `cost` (number, required): Current period cost - `change_percentage` (number, nullable): Change from previous period - `credits_summary` (object, nullable): Gross, credits and net for this month and last. Google Cloud only. - `gross` (number, required): Month-to-date cost before credits - `credits` (number, required): Month-to-date credits, zero or negative - `net` (number, required): Month-to-date cost after credits - `previous_gross` (number, required): Last month's cost before credits - `previous_credits` (number, required): Last month's credits, zero or negative - `previous_net` (number, required): Last month's cost after credits - `credits_pct_of_gross` (number, nullable): Share of this month's gross cost covered by credits - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider spending timeline Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-costs-timeline.md ## GET /api/v1/providers/{provider_id}/costs/timeline Returns spending timeline for a specific provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `start` (query, string, nullable): Start date in ISO 8601 format - `end` (query, string, nullable): End date in ISO 8601 format - `granularity` (query, string, default `daily`): Granularity: 'daily' or 'monthly' ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider spending timeline data - `provider_id` (string, required): Provider identifier - `provider_name` (string, required): Display name - `granularity` (string, required): Granularity: 'daily' or 'monthly' - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `data_points` (array of object, required): Timeline data points - `date` (string, required): Date in YYYY-MM-DD format - `amount` (number, required): Total cost for this day - `total` (number, required): Total cost for the period #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider potential savings breakdown Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-potential-savings-breakdown.md ## GET /api/v1/providers/{provider_id}/potential-savings/breakdown Returns paginated potential savings breakdown for a specific provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `page` (query, integer, default `1`): Page number (1-indexed) - `page_size` (query, integer, default `20`): Items per page (max 200) - `sort_by` (query, string, default `status`): Field to sort by - `sort_order` (query, one of `asc`, `desc`, default `asc`): Sort order ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider-specific breakdown with pagination - `total_savings` (number, required): Sum of monthly_savings for all recommendations (not just current page) - `total_savings_count` (integer, default `0`): Count of individual savings the total_savings sum covers (every row matching the filters, children and group members included). pagination.total_items counts only chain anchors, so total_savings_count is greater whenever dependencies or groups collapse rows in the table. - `items` (array of object, required) - `recommendation_id` (string, required): Unique identifier for the recommendation - `service` (string, required): Service name (e.g., EC2, EBS, RDS) - `resource_type` (string, nullable): What the recommendation was raised as. commitment_renewal and commitment_purchase buy a commitment, which only an organization admin releases, in the dashboard or through an MCP connection they authorized - `environment` (string, nullable): Environment (e.g., Production, Staging) - `account` (string, nullable): Cloud account identifier - `tag` (string, nullable): Resource tag value - `monthly_savings` (number, required): Monthly savings amount - `savings_percentage` (number, required): Savings as percentage of current spending - `status` (one of `pending`, `processing`, `in_progress`, `optimized`, `rejected`, `unavailable`, `failed`, default `pending`): Recommendation status - `display_status` (one of `available`, `awaiting_approval`, `pending`, `saved`, `rejected`, `unavailable`, default `available`): Status the person reads, served once so the dashboard, the CLI and MCP all print the same word. Derived from status, saving_acceptance and execution_request_status; a request waiting on an approver outranks everything else. Prefer this over status for display - `actions` (object, nullable): Action details for this recommendation - `description` (string, nullable): Brief summary of the recommendation - `overview` (string, nullable): Detailed overview explaining the recommendation (supports markdown) - `implementation_process` (string, nullable): How to implement this recommendation (supports markdown) - `execution_method` (string, nullable): Technical execution details (supports markdown) - `key_takeaway` (string, nullable): Important notes, risks, and safety information (supports markdown) - `operational_impact` (string, nullable): Impact on operations and any downtime requirements (supports markdown) - `terms_accepted_at` (string, nullable): When terms were accepted, null if not yet accepted - `saving_accepted_by` (string, nullable): User who authored/approved the savings decision - `saving_acceptance` (string, nullable): accepted or rejected once the tenant decides, null while still untouched - `implementation_method` (string, nullable): Chosen implementation method: one-click, iac, one-click-plus-iac, or manual - `implementation_status` (one of `in_progress`, `completed`, `failed`, `warning`, nullable): Rollout status: in_progress, completed, failed, or warning, null when no rollout is underway - `implementation_tags` (array of string): Implementation formats this savings can be delivered with (capabilities): Automated Savings, Automated Savings + IaC, IaC, or Manual Setup. A savings may carry more than one. Distinct from implementation_method, the single chosen path. - `created_at` (string, nullable): When the recommendation was created (ISO 8601) - `updated_at` (string, nullable): When the recommendation was last modified (ISO 8601), null if never modified - `group_id` (string, nullable): Id of the group this savings belongs to, null when ungrouped - `group_name` (string, nullable): Display name of the group this savings belongs to - `linked_to` (object, nullable) - `execution_request_status` (string, nullable): Dual-control execution state: pending_approval, approved, or rejected (null when never requested) - `execution_request_by` (string, nullable): Email of the member who requested execution, null when never requested - `execution_request_by_name` (string, nullable): Display name of the member who requested execution, null when unknown or never requested - `execution_request_at` (string, nullable): When execution was requested (ISO 8601), null when never requested - `virtual_tags` (map of string to string): Virtual tag values of this savings' resources, key name to value name. Where its resources carry several values of one key, the one holding the most spend - `risk_assessment` (object, nullable): Risk level denormalized from actions so the table Risk column renders. Carries the level only (LOW, MODERATE, HIGH, or CRITICAL); factors and sources stay on the detail endpoint. Null when the recommendation has no assessment - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider potential savings summary Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-potential-savings-summary.md ## GET /api/v1/providers/{provider_id}/potential-savings/summary Returns potential savings summary for a specific provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider-specific potential savings summary - `provider_id` (string, required): Provider identifier - `total_monthly_savings` (number, required): Total potential monthly savings - `total_current_spending` (number, required): Total current monthly spending - `total_recommendations` (integer, required): Number of pending recommendations - `savings_percentage` (number, required): Savings as percentage of current spending #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get Savings Audit breakdown by provider Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations-audit-breakdown.md ## GET /api/v1/providers/{provider_id}/recommendations/audit/breakdown Returns saved recommendations for a provider as paginated JSON (format=table) or a CSV download of the full filtered set (format=csv) ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `format` (query, string, default `table`): Response format: table (paginated JSON) or csv (full filtered set as a CSV download) - `start` (query, string, nullable): Start date filter (ISO 8601 format: YYYY-MM-DD or YYYY-MM) - `end` (query, string, nullable): End date filter (ISO 8601 format: YYYY-MM-DD or YYYY-MM) - `page` (query, integer, default `1`): Page number (1-indexed) - `page_size` (query, integer, default `10`): Items per page (max 200) - `sort_by` (query, string, default `period`): Field to sort by (period, account_id, environment, service, usage_type, usage_quantity, usage_unit, pre_optimization_cost, monthly_savings, requested_by, approved_by) - `sort_order` (query, one of `asc`, `desc`, default `desc`): Sort order - `preset` (query, string, nullable): Date preset: '30D', '6M', or '12M' (takes precedence over start/end) - `service` (query, array of string, nullable): Filter by service(s) - can be repeated for multiple values - `environment` (query, array of string, nullable): Filter by environment(s) - can be repeated for multiple values - `account_id` (query, array of string, nullable): Filter by account ID(s) - can be repeated for multiple values ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Saved by provider breakdown data with pagination - `total_savings` (number, required): Sum of monthly_savings for this provider (filtered) - `items` (array of object, required) - `audit_id` (integer, required): Realized savings audit row id - `recommendation_id` (string, required): Recommendation id - `savings_id` (string, required): User-facing savings identifier (alias of recommendation_id) - `period` (string, required): Period in YYYY-MM format - `implemented_at` (string, nullable): ISO 8601 timestamp of the cloud event that realized the savings - `account_id` (string, nullable): Cloud account ID - `environment` (string, nullable): Environment. Holds the account alias rather than a fixed vocabulary, e.g. payments-prod, aws.data.staging, production-web, Shared-Services. - `provider` (string, required): Provider identifier: aws, gcp, azure, k8s - `service` (string, required): Resource/service name - maps to Resource column in UI - `usage_type` (string, nullable): Usage type description; null when no Cost Explorer line item is available - `usage_quantity` (number, nullable): Usage quantity; null when no Cost Explorer line item is available - `usage_unit` (string, nullable): Usage unit; null when no Cost Explorer line item is available - `cost` (number, required): Pre-optimization cost (derived in v1) - `savings` (number, required): Realized savings (alias of realized_savings_usd) - `author` (string, nullable): Legacy alias of approved_by (saving_accepted_by, falls back to actor_principal); kept for older clients - `requested_by` (string, nullable): Member who requested the execution (execution_request_by); null when there was no explicit requester - `approved_by` (string, nullable): Dashboard user who approved the savings (saving_accepted_by); falls back to actor_principal - `applied_via` (string, required): How the apply was attributed (sourced from implementation_method) - `implementation_method` (string, nullable): How the rec was implemented: one-click / one-click-plus-iac / iac / manual - `first_view_at` (string, nullable): Earliest view of this rec on any channel (manual path) - `first_view_channel` (string, nullable): Channel of the earliest view: dashboard / cli / sdk-go / sdk-node / sdk-python / api - `view_count_at_audit` (integer, nullable): Total view count snapshot at audit reconcile time - `verification` (string, default `pending`): Verification state: pending / auto-by-worker / auto-by-worker-and-iac / auto-by-iac / auto-wizard-manual / manual-verified / manual-unverified - `estimated_savings_usd` (number, nullable): Estimated savings at apply time - `realized_savings_usd` (number, nullable): Settled realized savings for this line; null while the billing period is open or before settlement - `realized_savings_in_progress_usd` (number, nullable): Running CE-visible realized savings during an open billing period; finalized into realized_savings_usd ≥5 days after period end - `settled_at` (string, nullable): Timestamp at which realized_savings_usd was finalized for this period - `realized_savings_7d_usd` (number, nullable): Reconciliation snapshot 7 days post-apply (CE-driven) - `realized_savings_30d_usd` (number, nullable): Reconciliation snapshot 30 days post-apply (CE-driven, billable) - `snapshot_7d_at` (string, nullable): Timestamp the 7-day snapshot was taken - `snapshot_30d_at` (string, nullable): Timestamp the 30-day snapshot was taken - `realized_basis` (string, required): Source for realized savings calculation - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider-specific Savings Audit summary Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations-audit-summary.md ## GET /api/v1/providers/{provider_id}/recommendations/audit/summary Returns saved summary totals for a specific provider (for BigNumbers) ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider-specific Savings Audit summary for BigNumbers - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider_name` (string, required): Display name: AWS, GCP, etc. - `total_monthly_savings` (number, required): Total monthly savings for this provider - `total_monthly_savings_percentage` (number, nullable): MoM change percentage - `total_annual_savings` (number, required): Total annual savings (monthly * 12) - `average_roi` (number, required): Average ROI percentage - `total_items` (integer, required): Number of implemented savings #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider recommendation filter options Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations-filter-options.md ## GET /api/v1/providers/{provider_id}/recommendations/filter-options Returns distinct environments, accounts, and tags for recommendation filtering ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Filter options for provider recommendation breakdown - `environments` (array of string): Distinct environment values - `accounts` (array of string): Distinct account values - `tags` (array of string): Distinct tag values - `services` (array of string): Distinct service values #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider recommendations overview Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations-overview.md ## GET /api/v1/providers/{provider_id}/recommendations/overview Returns overview metrics (total spend, available/pending savings, saved ITD) for a provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Recommendations overview metrics for a provider - `total_spend` (number, required): Total cost from last month's complete billing cycle - `display_savings` (map of string to number): Monthly savings keyed by display status, the same word every surface prints. The per-status *_savings fields below are keyed on the raw column, so a client had to fold processing into pending and optimized into saved for itself. Read this instead - `display_counts` (map of string to integer): Recommendation counts keyed by display status. Same relationship to the per-status *_count fields as display_savings has to *_savings - `available_savings` (number, required): Savings opportunities identified and available to execute - `pending_savings` (number, required): Savings opportunities pending required actions - `optimized_savings` (number, nullable): Monthly savings summed across all recommendations with status optimized (the Saved tab total) - `rejected_savings` (number, nullable): Monthly savings summed across all recommendations with status rejected - `unavailable_savings` (number, nullable): Monthly savings summed across all recommendations with status unavailable - `processing_savings` (number, nullable): Monthly savings summed across all recommendations with status processing - `available_count` (integer, nullable): Count of pending recommendations not yet accepted (the Available tab pill) - `pending_count` (integer, nullable): Count of pending recommendations already accepted (the Pending tab pill) - `processing_count` (integer, nullable): Count of recommendations with status processing - `optimized_count` (integer, nullable): Count of recommendations with status optimized (the Saved tab pill) - `rejected_count` (integer, nullable): Count of recommendations with status rejected - `unavailable_count` (integer, nullable): Count of recommendations with status unavailable - `total_count` (integer, nullable): True total of all recommendations for the provider (the All tab pill). Not the sum of the per-status counts: rollout states such as failed have no tab, so summing the buckets under-counts. Matches the universe the table paginates. - `saved_itd` (number, required): Total savings implemented inception to date - `awaiting_approval_count` (integer, default `0`): Count of recommendations awaiting an admin approval - `awaiting_approval_savings` (number, default `0`): Sum of monthly_savings for recommendations awaiting approval - `potential_savings` (number, nullable): Sum of all recommendations' monthly_savings - `evaluation_available_savings` (number, nullable): Sum of allowed evaluation IDs' monthly_savings - `saved_itd_delta_pct` (number, nullable): Percent change of saved_itd vs the prior period (30d) - `total_pipeline_delta_pct` (number, nullable): Percent change of the Total Pipeline (available + pending + saved_itd) vs the pipeline as it stood 30 days ago. Signed (positive means the pipeline grew), null when there was no prior pipeline to compare against - `saved_itd_history` (array of number): 30-point daily cumulative saved series for the sparkline - `top_recommendation` (object, nullable): The single highest-savings pending recommendation for this provider - `id` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_name` (string, nullable) - `savings_value` (number, required) - `top_recommendations` (array of object): Top open (pending) recommendations for this provider, ranked by monthly savings descending (max 3) - `id` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_name` (string, nullable) - `savings_value` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider-specific recommendations summary Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations-summary.md ## GET /api/v1/providers/{provider_id}/recommendations/summary Returns top 7 recommendations for a specific cloud provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider-specific recommendations summary with top recommendations - `provider_id` (string, required): Provider identifier - `provider_name` (string, required): Provider display name - `top_recommendations` (array of object, required): Top 3 recommendations by savings - `recommendation_id` (string, required): Unique identifier for the recommendation - `service` (string, required): Service name (e.g., EC2, Cloud SQL, Virtual Machines) - `monthly_savings` (number, required): Monthly savings amount - `annual_savings` (number, required): Annual savings amount - `current_monthly_spending` (number, required): Current monthly spending for this resource - `savings_percentage` (number, required): Savings as percentage of current spending - `status` (one of `available`, `pending`, `processing`, `optimized`, `rejected`, `unavailable`, required): Display status - `display_status` (one of `available`, `awaiting_approval`, `pending`, `saved`, `rejected`, `unavailable`, default `available`): Status the person reads, served once so the dashboard, the CLI and MCP all print the same word. Derived from status, saving_acceptance and execution_request_status; a request waiting on an approver outranks everything else. Prefer this over status for display #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider top savers Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations-top-savers.md ## GET /api/v1/providers/{provider_id}/recommendations/top-savers Returns the top users by realized (captured) savings for a provider, ranked descending (max 3) ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Top savers leaderboard data for a provider. - `items` (array of object): Users ranked by captured savings descending (max 3); empty when no realized savings exist - `user_id` (string, required): Stable identifier for the user, used as the list key - `email` (string, required): Email of the user who approved the realized savings - `avatar_url` (string, nullable): User avatar URL, null when not resolved - `captured_savings` (number, required): Monthly USD savings captured (realized) from recommendations this user approved - `trend_pct` (number, default `0`): Signed period-over-period percent change of this user's captured savings (0 when no prior period) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get provider-specific recommendations breakdown Source: https://docs.levelfour.ai/api-reference/providers/get-providers-by-id-recommendations.md ## GET /api/v1/providers/{provider_id}/recommendations Returns paginated breakdown of recommendations for a specific provider ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider_id` (path, string, required) - `start` (query, string, nullable): Start date filter (ISO 8601 format: YYYY-MM-DD) - `end` (query, string, nullable): End date filter (ISO 8601 format: YYYY-MM-DD) - `page` (query, integer, default `1`): Page number (1-indexed) - `page_size` (query, integer, default `20`): Items per page (max 200) - `sort_by` (query, string, default `status`): Field to sort by (recommendation_id, service, environment, account, tag, monthly_savings, savings_percentage, status, saving_acceptance, implementation_method, implementation_status, created_at, updated_at, risk_assessment). risk_assessment ranks by severity (LOW, MODERATE, HIGH, CRITICAL); recommendations with no assessment sort last in both directions - `sort_order` (query, one of `asc`, `desc`, default `asc`): Sort order - `service` (query, array of string, nullable): Filter by service(s) - can be repeated for multiple values - `environment` (query, array of string, nullable): Filter by environment(s) - can be repeated for multiple values - `account` (query, array of string, nullable): Filter by account(s) - can be repeated for multiple values - `tag` (query, array of string, nullable): Filter by tag(s) - can be repeated for multiple values - `service_not` (query, array of string, nullable): Exclude service(s) - can be repeated for multiple values - `environment_not` (query, array of string, nullable): Exclude environment(s) - can be repeated for multiple values - `account_not` (query, array of string, nullable): Exclude account(s) - can be repeated for multiple values - `tag_not` (query, array of string, nullable): Exclude tag(s) - can be repeated for multiple values - `resource_type` (query, array of string, nullable): Filter by resource type(s), such as commitment_purchase or commitment_renewal - can be repeated for multiple values - `resource_type_not` (query, array of string, nullable): Exclude resource type(s) - can be repeated for multiple values - `display_status` (query, array of string, nullable): Filter by display status(es): available, pending, awaiting_approval, processing, optimized (alias: saved), in_progress, failed, rejected, unavailable - `search` (query, string, nullable): Search across recommendation fields (case-insensitive) - `savings` (query, array of string, nullable): Keep only linked savings of these types: 'parent' (dependency parent) or 'group' (aggregator) - `savings_not` (query, array of string, nullable): Exclude linked savings of these types: 'parent' or 'group' - `available_formats` (query, array of string, nullable): Keep only savings whose available formats intersect these values: automated, iac, or manual. Can be repeated for multiple values (OR match). - `available_formats_not` (query, array of string, nullable): Exclude savings whose available formats intersect these values: automated, iac, or manual. Can be repeated for multiple values. - `virtual_tag_key` (query, string, nullable): Virtual tag key, by id or name (case-insensitive). Needs virtual_tag_value - `virtual_tag_value` (query, array of string, nullable): Keep savings whose resources carry one of these values of virtual_tag_key. __unallocated__ matches resources no config assigned ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Provider-specific breakdown with pagination - `total_savings` (number, required): Sum of monthly_savings for all recommendations (not just current page) - `total_savings_count` (integer, default `0`): Count of individual savings the total_savings sum covers (every row matching the filters, children and group members included). pagination.total_items counts only chain anchors, so total_savings_count is greater whenever dependencies or groups collapse rows in the table. - `items` (array of object, required) - `recommendation_id` (string, required): Unique identifier for the recommendation - `service` (string, required): Service name (e.g., EC2, EBS, RDS) - `resource_type` (string, nullable): What the recommendation was raised as. commitment_renewal and commitment_purchase buy a commitment, which only an organization admin releases, in the dashboard or through an MCP connection they authorized - `environment` (string, nullable): Environment (e.g., Production, Staging) - `account` (string, nullable): Cloud account identifier - `tag` (string, nullable): Resource tag value - `monthly_savings` (number, required): Monthly savings amount - `savings_percentage` (number, required): Savings as percentage of current spending - `status` (one of `pending`, `processing`, `in_progress`, `optimized`, `rejected`, `unavailable`, `failed`, default `pending`): Recommendation status - `display_status` (one of `available`, `awaiting_approval`, `pending`, `saved`, `rejected`, `unavailable`, default `available`): Status the person reads, served once so the dashboard, the CLI and MCP all print the same word. Derived from status, saving_acceptance and execution_request_status; a request waiting on an approver outranks everything else. Prefer this over status for display - `actions` (object, nullable): Action details for this recommendation - `description` (string, nullable): Brief summary of the recommendation - `overview` (string, nullable): Detailed overview explaining the recommendation (supports markdown) - `implementation_process` (string, nullable): How to implement this recommendation (supports markdown) - `execution_method` (string, nullable): Technical execution details (supports markdown) - `key_takeaway` (string, nullable): Important notes, risks, and safety information (supports markdown) - `operational_impact` (string, nullable): Impact on operations and any downtime requirements (supports markdown) - `terms_accepted_at` (string, nullable): When terms were accepted, null if not yet accepted - `saving_accepted_by` (string, nullable): User who authored/approved the savings decision - `saving_acceptance` (string, nullable): accepted or rejected once the tenant decides, null while still untouched - `implementation_method` (string, nullable): Chosen implementation method: one-click, iac, one-click-plus-iac, or manual - `implementation_status` (one of `in_progress`, `completed`, `failed`, `warning`, nullable): Rollout status: in_progress, completed, failed, or warning, null when no rollout is underway - `implementation_tags` (array of string): Implementation formats this savings can be delivered with (capabilities): Automated Savings, Automated Savings + IaC, IaC, or Manual Setup. A savings may carry more than one. Distinct from implementation_method, the single chosen path. - `created_at` (string, nullable): When the recommendation was created (ISO 8601) - `updated_at` (string, nullable): When the recommendation was last modified (ISO 8601), null if never modified - `group_id` (string, nullable): Id of the group this savings belongs to, null when ungrouped - `group_name` (string, nullable): Display name of the group this savings belongs to - `linked_to` (object, nullable) - `execution_request_status` (string, nullable): Dual-control execution state: pending_approval, approved, or rejected (null when never requested) - `execution_request_by` (string, nullable): Email of the member who requested execution, null when never requested - `execution_request_by_name` (string, nullable): Display name of the member who requested execution, null when unknown or never requested - `execution_request_at` (string, nullable): When execution was requested (ISO 8601), null when never requested - `virtual_tags` (map of string to string): Virtual tag values of this savings' resources, key name to value name. Where its resources carry several values of one key, the one holding the most spend - `risk_assessment` (object, nullable): Risk level denormalized from actions so the table Risk column renders. Carries the level only (LOW, MODERATE, HIGH, or CRITICAL); factors and sources stay on the detail endpoint. Null when the recommendation has no assessment - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List connected cloud providers Source: https://docs.levelfour.ai/api-reference/providers/get-providers.md ## GET /api/v1/providers ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `provider_id` (string, required): Lowercase provider code (aws, gcp, azure, k8s) - `provider_name` (string, required): Human-readable provider name #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Costs Source: https://docs.levelfour.ai/api-reference/costs.md - [Get spending summary](/api-reference/costs/get-costs-summary): Returns spending summary: overall costs, forecast, savings, and provider breakdown - [Top growing services](/api-reference/costs/get-costs-top-growing): Returns services ranked by largest percent growth between current and previous period. - [List distinct tag keys](/api-reference/costs/get-costs-by-tag-keys): Returns the distinct provider tag keys present in the tenant's per-tag cost data and every virtual tag key, so the dashboard can offer both as choices. A provider key that a virtual key of the same name replaces is left out. - [Cost grouped by tag value](/api-reference/costs/get-costs-by-tag): Returns spend grouped by tag_value for a given tag_key (e.g., team, tribe, squad, cost-center). Includes a per-team category breakdown. - [Tag allocation coverage](/api-reference/costs/get-costs-allocation): Returns showback coverage for a tag key: total, tagged, untagged, coverage percentage, and a per-value breakdown. - [Google Cloud cost by a hierarchy of labels](/api-reference/costs/get-costs-by-labels): Returns Google Cloud spend grouped by up to three label keys at once, for example cluster, namespace and workload. A resource label wins over the same key set on its project. - [Usage and unit economics by usage type](/api-reference/costs/get-costs-usage): Returns spend, billable quantity, unit, and derived cost per unit grouped by usage type. - [Get spend forecast](/api-reference/costs/get-costs-forecast): Returns forecasted spend value, confidence bounds, and a 60-point sparkline (past 30 days actual + next 30 days forecast). - [Get cost breakdown](/api-reference/costs/get-costs-breakdown): Returns cost breakdown in different formats: table (paginated), chart (aggregated), csv (export), raw (all records) - [Get daily spending breakdown](/api-reference/costs/get-costs-daily-breakdown): Returns daily spending aggregated per day for the given date range - [Get monthly spending](/api-reference/costs/get-costs-monthly-spending): Returns monthly spending aggregated per month --- # Tag allocation coverage Source: https://docs.levelfour.ai/api-reference/costs/get-costs-allocation.md ## GET /api/v1/costs/allocation Returns showback coverage for a tag key: total, tagged, untagged, coverage percentage, and a per-value breakdown. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `tag_key` (query, string, nullable): Tag key to compute allocation coverage for. Google Cloud may omit it for any-label coverage. - `start` (query, string, nullable) - `end` (query, string, nullable) - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. - `exclude_system` (query, boolean, default `true`): Google Cloud only: ignore goog- and k8s- labels - `include_project_labels` (query, boolean, default `true`): Google Cloud only: count labels set on the project ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `tag_key` (string, required): Tag key the allocation is computed for, empty for any-label coverage - `total` (number, required): Total spend in the period - `tagged` (number, required): Spend attributed to a non-empty value of this tag key - `untagged` (number, required): Spend with no value for this tag key - `coverage_pct` (number, required): Percentage of total spend that is tagged - `by_value` (array of object, required): Per-value breakdown of tagged spend - `value` (string, required): Tag value - `cost` (number, required): Cost attributed to this tag value - `pct` (number, required): Share of tagged spend for this value - `by_key` (array of object, nullable): Coverage per label key. Google Cloud only. - `key` (string, required): Label key - `cost` (number, required): Spend carrying this key - `coverage_pct` (number, required): Share of total spend carrying this key - `sources` (array of string, required): Where the key was set: user, project, kubernetes or system - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get cost breakdown Source: https://docs.levelfour.ai/api-reference/costs/get-costs-breakdown.md ## GET /api/v1/costs/breakdown Returns cost breakdown in different formats: table (paginated), chart (aggregated), csv (export), raw (all records) ### Authentication Send `Authorization: Bearer `. ### Parameters - `format` (query, string, default `table`): Response format: table (paginated UI), chart (aggregated stacked bar), csv (export), raw (all records) - `period` (query, string, nullable): Period in YYYY-MM format (defaults to current month) - `provider_id` (query, string, nullable): Filter by provider: aws, gcp, azure, k8s - `granularity` (query, string, default `daily`): Granularity: 'daily' or 'monthly' (used for chart format) - `group_by` (query, string, nullable): Grouping dimension for chart format: 'provider' (default) or 'account_id' - `start` (query, string, nullable): Start date in ISO 8601 format (e.g., 2026-02-01T00:00:00.000Z). Used for chart and table formats - `end` (query, string, nullable): End date in ISO 8601 format (e.g., 2026-02-01T00:00:00.000Z). Used for chart and table formats - `page` (query, integer, default `1`): Page number (1-indexed, table format only) - `page_size` (query, integer, default `10`): Items per page (max 100, table format only) - `sort_by` (query, string, default `cost`): Sort field: cost, service, region, account_id, change_percentage - `sort_order` (query, one of `asc`, `desc`, default `desc`): Sort order ### Responses #### 200: Successful Response - Variant 1: CostBreakdownResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Cost breakdown data with pagination - `period` (string, required): Period in YYYY-MM format - `items` (array of object, required): Service cost breakdown items - `service` (string, required): Service name (e.g., Amazon EC2, Cloud SQL) - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `environment` (string, nullable): Environment: Production, Staging, etc. - `region` (string, nullable): Cloud region - `account_id` (string, nullable): Cloud account ID - `tag_key` (string, nullable): Tag key if present - `tag_value` (string, nullable): Tag value if present - `cost` (number, required): Current period cost - `previous_cost` (number, nullable): Previous period cost - `change_percentage` (number, nullable): Change from previous period - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page - Variant 2: CostsBreakdownChartResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Costs breakdown chart data for stacked bar visualization - `granularity` (string, required): Granularity: 'daily' or 'monthly' - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `data_points` (array of object, required): Time series data points - `date` (string, required): Date in YYYY-MM-DD (daily) or YYYY-MM (monthly) format - `aws` (number, default `0`): AWS spending for this period - `gcp` (number, default `0`): GCP spending for this period - `azure` (number, default `0`): Azure spending for this period - `k8s` (number, default `0`): Kubernetes spending for this period - `totals` (object, required): Totals by provider for the period - `aws` (number, default `0`): Total AWS spending - `gcp` (number, default `0`): Total GCP spending - `azure` (number, default `0`): Total Azure spending - `k8s` (number, default `0`): Total Kubernetes spending - Variant 3: CostsBreakdownGroupedChartResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Costs breakdown chart data grouped by account_id - `granularity` (string, required): Granularity: 'daily' or 'monthly' - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `groups` (array of string, required): Ordered list of group keys (account IDs) - `data_points` (array of object, required): Time series data points - `date` (string, required): Date in YYYY-MM-DD (daily) or YYYY-MM (monthly) format - `groups` (map of string to number, required): Group costs: {'111122223333': 185.30, '123456789012': 245.50} - `total` (number, required): Total cost for this date - `totals` (map of string to number, required): Total per group for the period - Variant 4: CostBreakdownRawResponse - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Cost breakdown raw data without pagination - `period` (string, required): Period in YYYY-MM format - `items` (array of object, required): All service cost breakdown items (no pagination) - `service` (string, required): Service name (e.g., Amazon EC2, Cloud SQL) - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `environment` (string, nullable): Environment: Production, Staging, etc. - `region` (string, nullable): Cloud region - `account_id` (string, nullable): Cloud account ID - `tag_key` (string, nullable): Tag key if present - `tag_value` (string, nullable): Tag value if present - `cost` (number, required): Current period cost - `previous_cost` (number, nullable): Previous period cost - `change_percentage` (number, nullable): Change from previous period - `total_items` (integer, required): Total number of items #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Google Cloud cost by a hierarchy of labels Source: https://docs.levelfour.ai/api-reference/costs/get-costs-by-labels.md ## GET /api/v1/costs/by-labels Returns Google Cloud spend grouped by up to three label keys at once, for example cluster, namespace and workload. A resource label wins over the same key set on its project. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, `gcp`, default `gcp`) - `key` (query, array of string, required): Label keys, in order - `filter` (query, array of string, nullable): key=value pairs to narrow to - `start` (query, string, nullable) - `end` (query, string, nullable) - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. - `include_project_labels` (query, boolean, default `true`) - `limit` (query, integer, default `50`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `keys` (array of string, required): The label keys, in the order they were requested - `credits` (one of `before`, `after`, required): Cost basis applied - `total` (number, required): Total spend in the window - `unlabeled` (number, required): Spend carrying none of the keys - `items` (array of object, required): Combinations ranked by spend - `values` (map of string to string, nullable, required): The value of each requested key, null when unset - `cost` (number, required): Spend for this combination - `share_pct` (number, required): Share of total spend #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List distinct tag keys Source: https://docs.levelfour.ai/api-reference/costs/get-costs-by-tag-keys.md ## GET /api/v1/costs/by-tag/keys Returns the distinct provider tag keys present in the tenant's per-tag cost data and every virtual tag key, so the dashboard can offer both as choices. A provider key that a virtual key of the same name replaces is left out. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `tag_keys` (array of string): Distinct provider tag keys present in the tenant's tag-cost data, less any a virtual key of the same name replaces - `tag_key_details` (array of object): Where each provider key was set: user, project, kubernetes or system - `key` (string, required) - `sources` (array of string, required) - `virtual_tag_keys` (array of object): Every virtual tag key - `id` (string, required): Virtual tag key id - `name` (string, required): Virtual tag key name #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Cost grouped by tag value Source: https://docs.levelfour.ai/api-reference/costs/get-costs-by-tag.md ## GET /api/v1/costs/by-tag Returns spend grouped by tag_value for a given tag_key (e.g., team, tribe, squad, cost-center). Includes a per-team category breakdown. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `tag_key` (query, string, default `team`) - `start` (query, string, nullable) - `end` (query, string, nullable) - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `tag_key` (string, required) - `origin` (one of `provider`, `virtual`, required): virtual when tag_key names a virtual tag, whose unmapped total is exactly the spend no rule assigned - `teams` (array of object, required) - `id` (string, required) - `label` (string, required) - `total` (number, required) - `total_pct` (number, required) - `categories` (object, required) - `compute` (number, default `0`) - `storage` (number, default `0`) - `network` (number, default `0`) - `database` (number, default `0`) - `other` (number, default `0`) - `unmapped` (object, required) - `total` (number, required) - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get daily spending breakdown Source: https://docs.levelfour.ai/api-reference/costs/get-costs-daily-breakdown.md ## GET /api/v1/costs/daily/breakdown Returns daily spending aggregated per day for the given date range ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, nullable): Start date in ISO 8601 format (e.g., 2026-02-01T00:00:00.000Z) - `end` (query, string, nullable): End date in ISO 8601 format (e.g., 2026-02-28T00:00:00.000Z) - `providerId` (query, string, nullable): Limit the series to one provider - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Daily spending breakdown data - `start_date` (string, required): Start date in YYYY-MM-DD format - `end_date` (string, required): End date in YYYY-MM-DD format - `data_points` (array of object, required): Daily spending data points - `date` (string, required): Date in YYYY-MM-DD format - `amount` (number, required): Total cost for this day - `total` (number, required): Total cost for the period - `providers_covered` (array of string): Cloud providers these totals span. This series is the one spend reader that covers every connected cloud at once, so a figure from it is not comparable with one from a single-provider endpoint unless this says it is. #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get spend forecast Source: https://docs.levelfour.ai/api-reference/costs/get-costs-forecast.md ## GET /api/v1/costs/forecast Returns forecasted spend value, confidence bounds, and a 60-point sparkline (past 30 days actual + next 30 days forecast). ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`): Provider to forecast, or all. A provider with no costs returns an empty forecast. - `horizon` (query, string, default `eom`): Forecast horizon - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `forecast_value` (number, required): Forecasted value for the horizon - `forecast_low` (number, required): Lower confidence bound - `forecast_high` (number, required): Upper confidence bound - `confidence_pct` (number, required): Confidence percent (default 80) - `sparkline` (array of number, required): 60 values: past 30 days actual + next 30 forecasted - `future_start_index` (integer, required): Index in sparkline where future starts (0-based) - `delta_pct_vs_last_month` (number, nullable): Forecast value vs previous month total, as percent - `booked_to_date` (number, nullable): Spend already booked this month - `daily_rate` (number, nullable): Median daily spend the projection uses - `days_remaining` (integer, nullable): Days the projection covers, counted from the last actual - `actuals_through` (string, nullable): Last day with billing data, YYYY-MM-DD. Days after it are projected, not booked. - `days_since_last_actual` (integer, nullable): How far behind today the billing data stops. Above zero means the projection covers days already past. - `daily_rate_sample_days` (integer, nullable): Days in the trailing window that carried any billing data - `daily_rate_thin_days` (integer, nullable): Sampled days worth less than a tenth of the median. Above zero usually means an account stopped delivering billing data rather than that spend fell. - `daily_rate_window_days` (integer, nullable): Length of the trailing window, in days - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get monthly spending Source: https://docs.levelfour.ai/api-reference/costs/get-costs-monthly-spending.md ## GET /api/v1/costs/monthly-spending Returns monthly spending aggregated per month ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Monthly spending data - `data_points` (array of object, required): Monthly spending data points - `month` (string, required): Month in YYYY-MM format - `amount` (number, required): Total cost for this month #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get spending summary Source: https://docs.levelfour.ai/api-reference/costs/get-costs-summary.md ## GET /api/v1/costs/summary Returns spending summary: overall costs, forecast, savings, and provider breakdown ### Authentication Send `Authorization: Bearer `. ### Parameters - `providerId` (query, string, nullable): Scope the summary to one provider. A provider the tenant has not enabled returns an empty summary. - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Spending summary data for BigNumbers section - `overall_monthly_costs` (number, required): Total spending across all providers for current month - `overall_monthly_costs_percentage` (number, nullable): Same-period MoM change percentage (current MTD vs same days in previous month) - `forecasted_monthly_costs` (number, required): Projected full month costs based on daily run rate - `forecasted_monthly_costs_percentage` (number, nullable): Forecast vs previous month actual percentage - `last_month_spend` (number, required): Total spending for the previous fully closed month - `last_month_spend_percentage` (number, nullable): MoM change percentage for previous month vs 2-months-ago - `savings_recommendations` (number, required): Total potential savings from pending recommendations - `savings_recommendations_percentage` (number, nullable): Change in savings recommendations vs previous month - `total_savings` (number, required): Total annual savings from Savings Audit - `providers` (array of object, required): Per-provider spending breakdown - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider_name` (string, required): Display name: AWS, GCP, etc. - `monthly_spending` (number, required): Current month spending for this provider - `monthly_spending_percentage` (number, nullable): MoM change percentage - `monthly_spending_change` (number, nullable): MoM change in dollar amount - `previous_month_spending` (number, nullable): Previous fully closed month total spending - `potential_savings` (number, required): Total potential monthly savings - `potential_savings_percentage` (number, required): Savings as percentage of spending - `spend_split` (object, nullable): Commit/On-Demand/Spot split for current MTD spend (AWS only). Null when data unavailable. - `commit_monthly` (number, required): Reserved/commit usage spend (current month) - `on_demand_monthly` (number, required): On-demand usage spend (current month) - `spot_monthly` (number, required): Spot usage spend (current month) - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Top growing services Source: https://docs.levelfour.ai/api-reference/costs/get-costs-top-growing.md ## GET /api/v1/costs/top-growing Returns services ranked by largest percent growth between current and previous period. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `compare_to` (query, string, default `previous_period`) - `limit` (query, integer, default `10`) - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `id` (string, required) - `name` (string, required) - `cost` (number, required) - `previous_cost` (number, required) - `delta_pct` (number, required) - `delta_absolute` (number, required) - `window` (object, nullable): The two windows compared. Null when the tenant has no cost data yet. - `current_start` (string, required): First day of the current window, YYYY-MM-DD - `current_end` (string, required): Last day of the current window: the last day with billing data - `previous_start` (string, required): First day of the window the current one is compared against - `previous_end` (string, required): Last day of the comparison window - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Usage and unit economics by usage type Source: https://docs.levelfour.ai/api-reference/costs/get-costs-usage.md ## GET /api/v1/costs/usage Returns spend, billable quantity, unit, and derived cost per unit grouped by usage type. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `start` (query, string, nullable) - `end` (query, string, nullable) - `service` (query, array of string, nullable): Google Cloud only: limit to these services - `family` (query, one of `bigquery`, `network`, `compute`, `other`, nullable): Google Cloud only: limit to one SKU family and return its categories - `credits` (query, one of `before`, `after`, default `after`): Cost basis for Google Cloud: after credits (default) or before them. AWS stays on its invoiced basis. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required): Usage types ranked by cost - `usage_type` (string, required): Usage type code, or the SKU description for Google Cloud - `cost` (number, required): Cost for this usage type - `usage_amount` (number, nullable): Billable quantity, in pricing units for Google Cloud. Null when none was reported. - `unit` (string, nullable): Pricing unit (GB-Month, Hrs, Requests, gibibyte month) - `cost_per_unit` (number, nullable): Derived cost per unit, null when quantity is zero or the rows mix units - `service` (string, nullable): Service billing this SKU. Google Cloud only. - `services` (array of string, nullable): Every service billing this SKU. Google Cloud only. - `family` (string, nullable): bigquery, network, compute or other. Google Cloud only. - `category` (string, nullable): Category within the family. Google Cloud only. - `sku_id` (string, nullable): Google Cloud SKU id - `gross_cost` (number, nullable): Cost before credits. Google Cloud only. - `total_cost` (number, required): Total cost across all usage types - `categories` (array of object, nullable): Cost by category. Google Cloud only. - `category` (string, required): Category within the requested family - `cost` (number, required): Cost in the category - `usage_amount` (number, nullable): Quantity, when every SKU in the category shares a unit - `unit` (string, nullable): The shared pricing unit - `credits` (one of `before`, `after`, nullable): Cost basis applied: after or before Google Cloud credits #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Google Cloud Source: https://docs.levelfour.ai/api-reference/google-cloud.md - [Count Google Cloud resources by type, region or project](/api-reference/google-cloud/get-providers-gcp-inventory-breakdown) - [Resources that bill while doing nothing](/api-reference/google-cloud/get-providers-gcp-inventory-waste-signals): Stopped VMs whose disks still bill, unattached disks, reserved addresses nobody uses and snapshots older than 90 days. - [Label coverage of Google Cloud resources](/api-reference/google-cloud/get-providers-gcp-inventory-label-hygiene): System labels (goog-*) never count as labeled. - [Utilization bands for VMs, Cloud SQL and GKE](/api-reference/google-cloud/get-providers-gcp-utilization-summary) - [Daily utilization of one resource](/api-reference/google-cloud/get-providers-gcp-utilization-series) - [Which projects and metrics Cloud Monitoring answered for](/api-reference/google-cloud/get-providers-gcp-utilization-coverage) - [Cloud Billing budgets with spend from the billing export](/api-reference/google-cloud/get-providers-gcp-budgets) - [What the LevelFour service account can read in Google Cloud](/api-reference/google-cloud/get-providers-gcp-access): probe rows come from IAM testIamPermissions; script_report rows are what a connect script applied. - [Which Google Cloud datasets hold data for this tenant](/api-reference/google-cloud/get-providers-gcp-data-availability): The Google Cloud board shows a card only when the dataset behind it is true. --- # What the LevelFour service account can read in Google Cloud Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-access.md ## GET /api/v1/providers/gcp/access probe rows come from IAM testIamPermissions; script_report rows are what a connect script applied. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `checks` (array of object, required) - `scope_type` (one of `project`, `organization`, `billing_account`, required) - `scope_id` (string, required) - `permission` (string, required): An IAM permission for probe rows, a role for script_report rows - `granted` (boolean, required) - `source` (one of `probe`, `script_report`, required) - `checked_at` (date-time string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Cloud Billing budgets with spend from the billing export Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-budgets.md ## GET /api/v1/providers/gcp/budgets ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `items` (array of object, required) - `budget_id` (string, required) - `display_name` (string, required) - `amount_usd` (number, nullable, required) - `amount_kind` (one of `specified`, `last_period`, required) - `calendar_period` (string, nullable, required): MONTH, QUARTER or YEAR; null for a custom period - `spent` (number, required): Spend in the current period, on the budget's own credit treatment - `forecast` (number, nullable, required): Straight-line projection to the end of the current period - `burn_pct` (number, nullable, required) - `thresholds` (array of number, required): Alert thresholds as percents of the amount - `approximate` (boolean, required): True when the budget's scope cannot be reproduced exactly #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Which Google Cloud datasets hold data for this tenant Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-data-availability.md ## GET /api/v1/providers/gcp/data-availability The Google Cloud board shows a card only when the dataset behind it is true. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `credits` (boolean, required): Credits by type are loaded - `purchase_type` (boolean, required): Spend is split by pricing type - `folders` (boolean, required): At least one project sits in a folder - `commitments` (boolean, required): An active or expiring committed use discount exists - `gke` (boolean, required): Spend carries a GKE cluster label - `labels` (boolean, required): Spend carries a label the customer set - `usage` (boolean, required): SKU usage exists in the last 30 days - `bigquery` (boolean, required): BigQuery usage exists in the last 30 days - `network` (boolean, required): Networking usage exists in the last 30 days - `invoices` (boolean, required): An invoice month is loaded - `utilization` (boolean, required): Cloud Monitoring utilization is loaded - `budgets` (boolean, required): A Cloud Billing budget is loaded - `inventory` (boolean, required): Resources are in the inventory - `waste_signals` (boolean, required): A waste signal counts at least one resource - `recommendations` (boolean, required): A Google Cloud savings recommendation exists - `anomalies` (boolean, required): A Google Cloud anomaly is active - `growth` (boolean, required): Enough history exists to rank growing services #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Count Google Cloud resources by type, region or project Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-inventory-breakdown.md ## GET /api/v1/providers/gcp/inventory/breakdown ### Authentication Send `Authorization: Bearer `. ### Parameters - `by` (query, one of `type`, `region`, `project`, default `type`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `by` (one of `type`, `region`, `project`, required) - `items` (array of object, required) - `key` (string, required) - `count` (integer, required) - `cost_mtd` (number, nullable, required): Detailed export cost for the resource, net of every credit except promotional and free-tier credits. Null when no Detailed export names resources. - `cost_last_month` (number, nullable, required): Detailed export cost for the resource, net of every credit except promotional and free-tier credits. Null when no Detailed export names resources. - `cost_attribution` (one of `available`, `unavailable`, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Label coverage of Google Cloud resources Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-inventory-label-hygiene.md ## GET /api/v1/providers/gcp/inventory/label-hygiene System labels (goog-*) never count as labeled. ### Authentication Send `Authorization: Bearer `. ### Parameters - `keys` (query, array of string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `items` (array of object, required) - `resource_type` (string, required) - `total` (integer, required) - `labeled` (integer, required): Resources carrying every requested key, or any user label when none is given - `coverage_pct` (number, required) - `top_unlabeled` (array of object, required) - `resource_id` (string, required) - `resource_type` (string, required) - `project_id` (string, required) - `monthly_cost` (number, nullable, required): Last full month. Detailed export cost for the resource, net of every credit except promotional and free-tier credits. Null when no Detailed export names resources. #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Resources that bill while doing nothing Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-inventory-waste-signals.md ## GET /api/v1/providers/gcp/inventory/waste-signals Stopped VMs whose disks still bill, unattached disks, reserved addresses nobody uses and snapshots older than 90 days. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `signals` (array of object, required) - `kind` (one of `stopped_vm_disks`, `unattached_disk`, `unused_address`, `old_snapshot`, required) - `count` (integer, required) - `size_gib` (number, nullable, required): Provisioned disk size, or snapshot storage, in GiB - `monthly_cost` (number, nullable, required): Last full month. Detailed export cost for the resource, net of every credit except promotional and free-tier credits. Null when no Detailed export names resources. - `recommendation_ids` (array of string, required): Open recommendations that act on these resources - `cost_attribution` (one of `available`, `unavailable`, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Which projects and metrics Cloud Monitoring answered for Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-utilization-coverage.md ## GET /api/v1/providers/gcp/utilization/coverage ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `items` (array of object, required) - `project_id` (string, required) - `metric` (string, required) - `status` (one of `complete`, `forbidden`, `failed`, required) - `last_day` (date string, nullable, required) - `series_count` (integer, required) - `last_error` (string, nullable, required) - `last_attempt_at` (date-time string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Daily utilization of one resource Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-utilization-series.md ## GET /api/v1/providers/gcp/utilization/series ### Authentication Send `Authorization: Bearer `. ### Parameters - `resource_id` (query, string, required) - `metric` (query, string, default `cpu_utilization`) - `days` (query, integer, default `30`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `resource_id` (string, required) - `metric` (string, required) - `points` (array of object, required) - `day` (date string, required) - `mean` (number, nullable, required) - `max` (number, nullable, required) - `p95` (number, nullable, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Utilization bands for VMs, Cloud SQL and GKE Source: https://docs.levelfour.ai/api-reference/google-cloud/get-providers-gcp-utilization-summary.md ## GET /api/v1/providers/gcp/utilization/summary ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (string, required) - `data` (object, required) - `vm` (object, required): Bands on the p95 of daily max CPU over 30 days - `total` (integer, required) - `bands` (array of object, required) - `band` (one of `idle`, `low`, `moderate`, `high`, required) - `count` (integer, required) - `sql` (object, required) - `total` (integer, required) - `bands` (array of object, required) - `band` (one of `idle`, `low`, `moderate`, `high`, required) - `count` (integer, required) - `gke` (object, required) - `node_cpu_pct` (number, nullable, required) - `node_memory_pct` (number, nullable, required) - `namespaces` (array of object, required) - `cluster` (string, required) - `namespace` (string, required) - `cpu_request_utilization` (number, nullable, required) - `memory_request_utilization` (number, nullable, required) - `coverage` (object, required) - `projects_complete` (integer, required) - `projects_total` (integer, required) - `ops_agent_share` (number, nullable, required): Percent of VMs reporting memory through the Ops Agent #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Anthropic Source: https://docs.levelfour.ai/api-reference/anthropic.md - [Which Anthropic datasets hold data](/api-reference/anthropic/get-providers-anthropic-data-availability): True for each dataset that has something to show, so the board hides the cards that do not. costs is billed spend, sku and resource are billed spend with a token type or a named key or person, usage_value is Claude Code and Cowork usage rolled into the cost tables, and usage is that usage as it was reported, which is what the usage routes read. people and cowork say more about the reported usage: whether two or more people reported any, and whether Cowork did. list_price is Claude Enterprise spend that carries a list price. activity has one flag per kind of activity Claude Code reported, and fleet, managed_settings, organizations and teams say what its installs reported about themselves. The activity flags and those four look at no range: each is true while anything of it is stored, whatever its day. - [Claude Code and Cowork usage, in total and per day](/api-reference/anthropic/get-providers-anthropic-usage): Usage is what Claude Code and Cowork reported about themselves, valued at API list price. It is not what the organization is billed, and it includes the current day. totals covers the range, with each person counted once. daily has one entry per day of the range, at zero on a day nobody used Claude. - [Claude Code and Cowork usage by person, by model or by product](/api-reference/anthropic/get-providers-anthropic-usage-breakdown): Usage is what Claude Code and Cowork reported about themselves, valued at API list price. It is not what the organization is billed, and it includes the current day. Rows are ordered by usage value, largest first. share is a row's part of the whole range, whatever search narrows the rows to. Grouped by person the answer names people, so it is sent with Cache-Control: no-store. - [How Claude Code and Cowork usage is spread across people](/api-reference/anthropic/get-providers-anthropic-usage-distribution): Usage is what Claude Code and Cowork reported about themselves, valued at API list price. It is not what the organization is billed, and it includes the current day. Each person is counted in the band their usage value over the range falls in, and every band is answered, including one with nobody in it. top_decile and rest set the tenth of the people who used the most beside everyone else. - [Claude Enterprise at list price, against what it cost after discount](/api-reference/anthropic/get-providers-anthropic-costs-list-price): What each Claude product would have cost at Anthropic's list price, beside what it cost after discount, over the range. The amount after discount is the same whether Anthropic or a marketplace invoices it. Anthropic reports a list price for Claude Enterprise alone, so an organization on another plan is answered with zeros. - [What Claude Code was used for, by kind of activity](/api-reference/anthropic/get-providers-anthropic-activity): Activity is what Claude Code and Cowork reported about themselves, kept as daily totals: names, counts and durations, never the text of a prompt, a response or a command. sections has one entry per kind of activity over the range, with its largest rows. response_times estimates, per model, how long a request took and how long its first token took: the times are kept in buckets, so each figure can be off by the width of one. people says how many people each kind of activity reached, and names nobody. - [Claude Code activity per day](/api-reference/anthropic/get-providers-anthropic-activity-daily): Activity is what Claude Code and Cowork reported about themselves, kept as daily totals: names, counts and durations, never the text of a prompt, a response or a command. A series per key of the measures the board charts (session, active_time, prompt, permission_mode, api_error, lines, git_output). Every day of the range has a value, zero on a day nothing was reported. - [The Claude Code installs of the people who used Claude](/api-reference/anthropic/get-providers-anthropic-activity-fleet): The people counted are the ones who reported usage in the range. A profile is kept per person, not per install: someone with two installs, such as a terminal and an editor extension on different versions, is shown as the one that reported last. Versions, terminals, operating systems, managed settings, Claude organizations and team tags, each as a count of people. Someone whose install did not report a value is left out of that list. Names nobody. - [The people behind the largest activity figures](/api-reference/anthropic/get-providers-anthropic-activity-people): Activity is what Claude Code and Cowork reported about themselves, kept as daily totals: names, counts and durations, never the text of a prompt, a response or a command. Each list names at most three people, the largest figure first, and nobody whose figure is zero. behind_on_version and without_managed_settings count everyone they apply to among the people who reported usage in the range, and name three. The answer names people, so it is sent with Cache-Control: no-store. - [People for whom Claude has reported nothing lately](/api-reference/anthropic/get-providers-anthropic-usage-inactive): Measured from today in UTC, with no range: how many people had nothing reported for them in the last 14 days and in the last 30, and the ten away the longest. Someone is last seen on the latest day any of their usage or activity was reported, and someone last seen more than 400 days ago is not counted. The answer names people, so it is sent with Cache-Control: no-store. - [People who started using Claude, came back to it or kept using it](/api-reference/anthropic/get-providers-anthropic-usage-adoption): Everyone who reported usage in the range is one of three: new when they were first seen inside it, continuing when they also reported usage in the range of the same length just before, and returning otherwise. At most five are named: up to three of the new and up to two of the returning, each kind taking the places the other leaves empty. The answer names people, so it is sent with Cache-Control: no-store. --- # Claude Code activity per day Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-activity-daily.md ## GET /api/v1/providers/anthropic/activity/daily Activity is what Claude Code and Cowork reported about themselves, kept as daily totals: names, counts and durations, never the text of a prompt, a response or a command. A series per key of the measures the board charts (session, active_time, prompt, permission_mode, api_error, lines, git_output). Every day of the range has a value, zero on a day nothing was reported. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `days` (array of date string, required): Every day of the range, first to last - `series` (array of object, required): At most five keys per measure, the one with the most events first, then the rest as (other) - `measure` (string, required) - `key` (string, required): What was counted, or (other) for every key past the five largest added up - `events` (array of integer, required): One count per day, in the order of days - `amount` (array of number, required): One amount per day, in the order of days - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # The Claude Code installs of the people who used Claude Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-activity-fleet.md ## GET /api/v1/providers/anthropic/activity/fleet The people counted are the ones who reported usage in the range. A profile is kept per person, not per install: someone with two installs, such as a terminal and an editor extension on different versions, is shown as the one that reported last. Versions, terminals, operating systems, managed settings, Claude organizations and team tags, each as a count of people. Someone whose install did not report a value is left out of that list. Names nobody. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `people` (integer, required): People who reported usage in the range. Each is shown as their install last reported itself, and someone with two installs as the one that reported last - `latest_version` (string, nullable, required): The highest Claude Code version any of them reported. Null when none reported a version - `versions` (array of object, required): Claude Code versions, the most people first, at most ten - `name` (string, required) - `people` (integer, required) - `terminals` (array of object, required): Terminals and IDEs, the most people first, at most ten - `name` (string, required) - `people` (integer, required) - `operating_systems` (array of object, required): Operating systems, the most people first, at most ten - `name` (string, required) - `people` (integer, required) - `managed_settings` (object, required) - `managed` (integer, required): People whose install runs with settings their organization manages - `unmanaged` (integer, required): People whose install runs with no managed settings - `organizations` (object, required) - `main` (object, required): The Claude organization most people report from - `people` (integer, required) - `usage_value` (number, required): Their usage value over the range, in USD - `other` (object, required): Everyone reporting from any other Claude organization - `people` (integer, required) - `usage_value` (number, required): Their usage value over the range, in USD - `teams` (array of object, required): People and usage value per team tag, at most ten. Empty when nobody's install carries a tag - `people` (integer, required) - `usage_value` (number, required): Their usage value over the range, in USD - `name` (string, required): The team tag, or an empty text for people whose install carries none - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # The people behind the largest activity figures Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-activity-people.md ## GET /api/v1/providers/anthropic/activity/people Activity is what Claude Code and Cowork reported about themselves, kept as daily totals: names, counts and durations, never the text of a prompt, a response or a command. Each list names at most three people, the largest figure first, and nobody whose figure is zero. behind_on_version and without_managed_settings count everyone they apply to among the people who reported usage in the range, and name three. The answer names people, so it is sent with Cache-Control: no-store. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `most_active` (array of object, required): The three people with the most active time - `person` (string, required) - `active_seconds` (integer, required) - `most_errors` (array of object, required): The three people with the most failed requests - `person` (string, required) - `api_errors` (integer, required) - `rate_limits` (integer, required): The errors among them that were rate limits (status 429) - `most_lines` (array of object, required): The three people with the most lines added and removed - `person` (string, required) - `lines_added` (integer, required) - `lines_removed` (integer, required) - `most_commits` (array of object, required): The three people with the most commits and pull requests - `person` (string, required) - `commits` (integer, required) - `pull_requests` (integer, required) - `behind_on_version` (object, required) - `people` (integer, required): People whose install reported a version below the latest one reported - `items` (array of object, required): The three on the oldest versions, oldest first - `person` (string, required) - `version` (string, required) - `without_managed_settings` (object, required) - `people` (integer, required): People whose install runs with no managed settings - `items` (array of object, required): Three of them, by name - `person` (string, required) - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # What Claude Code was used for, by kind of activity Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-activity.md ## GET /api/v1/providers/anthropic/activity Activity is what Claude Code and Cowork reported about themselves, kept as daily totals: names, counts and durations, never the text of a prompt, a response or a command. sections has one entry per kind of activity over the range, with its largest rows. response_times estimates, per model, how long a request took and how long its first token took: the times are kept in buckets, so each figure can be off by the width of one. people says how many people each kind of activity reached, and names nobody. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `sections` (array of object, required): One entry per measure, in a fixed order, including a measure nothing was counted for - `events` (integer, required): How often it happened. For active_time and lines it is the number of reports received, each covering some seconds or lines: read their amount - `amount` (number, required): What the measure adds up besides a count: milliseconds for tool and mcp_connected, seconds for active_time, characters for prompt, lines for lines, USD of usage value for request_source and repository, and 0 for a measure that only counts - `measure` (string, required): The kind of activity, such as tool, plugin or session - `items` (array of object, required): The 200 largest rows of the measure, largest first: by amount for request_source and repository, by events for every other measure - `events` (integer, required): How often it happened. For active_time and lines it is the number of reports received, each covering some seconds or lines: read their amount - `amount` (number, required): What the measure adds up besides a count: milliseconds for tool and mcp_connected, seconds for active_time, characters for prompt, lines for lines, USD of usage value for request_source and repository, and 0 for a measure that only counts - `key` (string, required): What was counted, such as a tool, a plugin or a language - `detail` (string, required): What tells rows of one key apart, such as an outcome or a version - `more` (object, required): Every row past the 200 listed, added up - `events` (integer, required): How often it happened. For active_time and lines it is the number of reports received, each covering some seconds or lines: read their amount - `amount` (number, required): What the measure adds up besides a count: milliseconds for tool and mcp_connected, seconds for active_time, characters for prompt, lines for lines, USD of usage value for request_source and repository, and 0 for a measure that only counts - `response_times` (array of object, required): One entry per model that was timed, the model with the most requests first, at most 50 - `model` (string, required): The model, or (other) for times the intake could not keep under a model - `requests` (integer, required): Requests that said how long they took - `first_token_p50_ms` (integer, nullable, required): Estimated median time to the first token, in ms. Null when no request said it, as with requests that were not streamed - `first_token_p90_ms` (integer, nullable, required): Estimated time to the first token that 9 in 10 requests stayed under, in ms. Null as the median is - `total_p50_ms` (integer, nullable, required): Estimated median time of a whole request, in ms. Null for the model (other) - `total_p90_ms` (integer, nullable, required): Estimated time of a whole request that 9 in 10 requests stayed under, in ms. Null as the median is - `people` (object, required): How many people each kind of activity reached, each person counted once - `sessions` (integer, required): People who started a session - `active_time` (integer, required): People with active time - `prompts` (integer, required): People who sent a prompt - `api_errors` (integer, required): People who had a model request fail - `lines` (integer, required): People for whom Claude Code added or removed lines of code - `git_output` (integer, required): People for whom Claude Code made a commit or opened a pull request - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Claude Enterprise at list price, against what it cost after discount Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-costs-list-price.md ## GET /api/v1/providers/anthropic/costs/list-price What each Claude product would have cost at Anthropic's list price, beside what it cost after discount, over the range. The amount after discount is the same whether Anthropic or a marketplace invoices it. Anthropic reports a list price for Claude Enterprise alone, so an organization on another plan is answered with zeros. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `totals` (object, required) - `list_cost` (number, required): Cost at Anthropic's list price, in USD - `effective_cost` (number, required): The amount after discount, in USD, whether Anthropic or a marketplace invoices it - `discount` (number, required): list_cost minus effective_cost, in USD - `discount_share` (number, required): discount as a part of list_cost. 0 when nothing has a list price - `items` (array of object, required): One entry per product, largest list_cost first - `list_cost` (number, required): Cost at Anthropic's list price, in USD - `effective_cost` (number, required): The amount after discount, in USD, whether Anthropic or a marketplace invoices it - `discount` (number, required): list_cost minus effective_cost, in USD - `product` (string, required): The Claude product's key, such as claude_code or chat - `product_name` (string, required): The product's display name, such as Claude Code or Chat - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Which Anthropic datasets hold data Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-data-availability.md ## GET /api/v1/providers/anthropic/data-availability True for each dataset that has something to show, so the board hides the cards that do not. costs is billed spend, sku and resource are billed spend with a token type or a named key or person, usage_value is Claude Code and Cowork usage rolled into the cost tables, and usage is that usage as it was reported, which is what the usage routes read. people and cowork say more about the reported usage: whether two or more people reported any, and whether Cowork did. list_price is Claude Enterprise spend that carries a list price. activity has one flag per kind of activity Claude Code reported, and fleet, managed_settings, organizations and teams say what its installs reported about themselves. The activity flags and those four look at no range: each is true while anything of it is stored, whatever its day. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `costs` (boolean, required) - `sku` (boolean, required) - `resource` (boolean, required) - `usage_value` (boolean, required) - `usage` (boolean, required) - `people` (boolean, required): Two or more people have reported usage - `cowork` (boolean, required): Cowork has reported usage - `list_price` (boolean, required): Claude Enterprise spend carries a list price - `anomalies` (boolean, required) - `activity` (object, required): One flag per kind of activity Claude Code reported, true once any of it was stored - `session` (boolean, required) - `active_time` (boolean, required) - `prompt` (boolean, required) - `tool` (boolean, required) - `tool_decision` (boolean, required) - `edit_decision` (boolean, required) - `mcp_connected` (boolean, required) - `mcp_failed` (boolean, required) - `plugin` (boolean, required) - `plugin_source` (boolean, required) - `skill` (boolean, required) - `permission_mode` (boolean, required) - `api_error` (boolean, required) - `lines` (boolean, required) - `git_output` (boolean, required) - `request_source` (boolean, required) - `request_effort` (boolean, required) - `request_speed` (boolean, required) - `response_time` (boolean, required): Requests were timed, in whole or to their first token - `repository` (boolean, required) - `fleet` (boolean, required): An install has reported what it is: its version, terminal or operating system - `managed_settings` (boolean, required): An install has said whether its settings are managed - `organizations` (boolean, required): Installs report from two or more Claude organizations - `teams` (boolean, required): An install carries a team tag - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # People who started using Claude, came back to it or kept using it Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-usage-adoption.md ## GET /api/v1/providers/anthropic/usage/adoption Everyone who reported usage in the range is one of three: new when they were first seen inside it, continuing when they also reported usage in the range of the same length just before, and returning otherwise. At most five are named: up to three of the new and up to two of the returning, each kind taking the places the other leaves empty. The answer names people, so it is sent with Cache-Control: no-store. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `active` (integer, required): People who reported usage in the range - `continuing` (integer, required): Those who also reported usage in the range of the same length just before - `new` (integer, required): Those first seen inside the range - `returning` (integer, required): Those seen before, and not in the range just before - `items` (array of object, required): At most five: up to three new people, the earliest first day first, then up to two returning people, the longest away first. When one kind has fewer, the other takes its places - `person` (string, required) - `status` (one of `new`, `returning`, required) - `first_seen` (date string, required): The first day usage or activity was reported for them - `away_days` (integer, nullable, required): For someone returning, the days from their last usage before the range to their first in it. Null for someone new, and for someone away longer than the 400 days looked back over - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Claude Code and Cowork usage by person, by model or by product Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-usage-breakdown.md ## GET /api/v1/providers/anthropic/usage/breakdown Usage is what Claude Code and Cowork reported about themselves, valued at API list price. It is not what the organization is billed, and it includes the current day. Rows are ordered by usage value, largest first. share is a row's part of the whole range, whatever search narrows the rows to. Grouped by person the answer names people, so it is sent with Cache-Control: no-store. ### Authentication Send `Authorization: Bearer `. ### Parameters - `group_by` (query, one of `person`, `model`, `product`, default `person`): What each row is - `page` (query, integer, default `1`) - `page_size` (query, integer, default `10`) - `search` (query, string, default ``): Keep the rows whose name contains this - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `items` (array of object, required) - `name` (string, required): The person, the model, or the product: claude_code or cowork - `usage_value` (number, required) - `requests` (integer, required) - `tokens` (integer, required): The four kinds of token added up - `input_tokens` (integer, required) - `output_tokens` (integer, required) - `cache_read_tokens` (integer, required): Input tokens read from the prompt cache - `cache_creation_tokens` (integer, required): Input tokens written to the prompt cache - `share` (number, required) - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # How Claude Code and Cowork usage is spread across people Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-usage-distribution.md ## GET /api/v1/providers/anthropic/usage/distribution Usage is what Claude Code and Cowork reported about themselves, valued at API list price. It is not what the organization is billed, and it includes the current day. Each person is counted in the band their usage value over the range falls in, and every band is answered, including one with nobody in it. top_decile and rest set the tenth of the people who used the most beside everyone else. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `people` (integer, required): People who reported usage in the range - `bands` (array of object, required): Every band, smallest first, including one with nobody in it - `floor` (integer, required): Smallest usage value in the band, in USD - `ceiling` (integer, nullable, required): Usage value the band stops short of, in USD. Null for the last band - `people` (integer, required): People whose usage value over the range is in the band - `usage_value` (number, required): Their usage value added up, in USD - `top_decile` (object, required): The tenth of the people with the largest usage value, a half rounding up, and at least one once anyone has reported usage - `people` (integer, required) - `usage_value` (number, required): Their usage value added up, in USD - `share` (number, required): Their part of everyone's usage value, 0 to 1 - `rest` (object, required): Everyone else - `people` (integer, required) - `usage_value` (number, required): Their usage value added up, in USD - `share` (number, required): Their part of everyone's usage value, 0 to 1 - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # People for whom Claude has reported nothing lately Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-usage-inactive.md ## GET /api/v1/providers/anthropic/usage/inactive Measured from today in UTC, with no range: how many people had nothing reported for them in the last 14 days and in the last 30, and the ten away the longest. Someone is last seen on the latest day any of their usage or activity was reported, and someone last seen more than 400 days ago is not counted. The answer names people, so it is sent with Cache-Control: no-store. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `inactive_14` (integer, required): People last seen 14 to 400 days ago - `inactive_30` (integer, required): People last seen 30 to 400 days ago - `items` (array of object, required): The ten of them away the longest, longest first - `person` (string, required) - `last_seen` (date string, required): The last day usage or activity was reported for them - `days` (integer, required): Days from that day to today - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Claude Code and Cowork usage, in total and per day Source: https://docs.levelfour.ai/api-reference/anthropic/get-providers-anthropic-usage.md ## GET /api/v1/providers/anthropic/usage Usage is what Claude Code and Cowork reported about themselves, valued at API list price. It is not what the organization is billed, and it includes the current day. totals covers the range, with each person counted once. daily has one entry per day of the range, at zero on a day nobody used Claude. ### Authentication Send `Authorization: Bearer `. ### Parameters - `start` (query, string, required): First day, ISO 8601 (YYYY-MM-DD or a datetime) - `end` (query, string, required): Last day, ISO 8601 (YYYY-MM-DD or a datetime), included ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, required) - `totals` (object, required) - `usage_value` (number, required) - `requests` (integer, required) - `active_people` (integer, required) - `input_tokens` (integer, required) - `output_tokens` (integer, required) - `cache_read_tokens` (integer, required) - `cache_creation_tokens` (integer, required) - `daily` (array of object, required) - `usage_value` (number, required) - `requests` (integer, required) - `active_people` (integer, required) - `input_tokens` (integer, required) - `output_tokens` (integer, required) - `cache_read_tokens` (integer, required) - `cache_creation_tokens` (integer, required) - `date` (date string, required) - `timestamp` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Tags Source: https://docs.levelfour.ai/api-reference/tags.md - [List provider and virtual tag keys](/api-reference/tags/get-tags-keys) - [Tagged share of spend and resources](/api-reference/tags/get-tags-coverage) - [One tag key](/api-reference/tags/get-tags-keys-by-id) - [Resources carrying a key](/api-reference/tags/get-tags-keys-by-id-resources) - [Resources a condition can pin](/api-reference/tags/get-tags-resources) - [Filter catalog for tag rules](/api-reference/tags/get-tags-dimensions) - [Values a condition can match](/api-reference/tags/get-tags-dimensions-by-id-values) - [Dry run a virtual tag definition](/api-reference/tags/post-tags-virtual-preview) - [Create a virtual tag](/api-reference/tags/post-tags-virtual) - [Update virtual tag settings](/api-reference/tags/patch-tags-virtual-by-id) - [Delete a virtual tag](/api-reference/tags/delete-tags-virtual-by-id) - [Replace a virtual tag](/api-reference/tags/put-tags-virtual-by-id) - [Replace a virtual tag's collapsed keys](/api-reference/tags/put-tags-virtual-by-id-collapsed-keys) - [Append a config](/api-reference/tags/post-tags-virtual-by-id-configs) - [Reorder configs](/api-reference/tags/put-tags-virtual-by-id-configs-order) - [Delete a config](/api-reference/tags/delete-tags-virtual-by-id-configs-by-id) - [Replace a config](/api-reference/tags/put-tags-virtual-by-id-configs-by-id) - [Rename a virtual tag value](/api-reference/tags/patch-tags-virtual-by-id-values-by-id) - [Reprocessing job progress](/api-reference/tags/get-tags-jobs-by-id) --- # Delete a config Source: https://docs.levelfour.ai/api-reference/tags/delete-tags-virtual-by-id-configs-by-id.md ## DELETE /api/v1/tags/virtual/{key_id}/configs/{config_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) - `config_id` (path, string, required) - `expected_version` (query, integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Delete a virtual tag Source: https://docs.levelfour.ai/api-reference/tags/delete-tags-virtual-by-id.md ## DELETE /api/v1/tags/virtual/{key_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) ### Responses #### 204: Successful Response #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Tagged share of spend and resources Source: https://docs.levelfour.ai/api-reference/tags/get-tags-coverage.md ## GET /api/v1/tags/coverage ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, one of `aws`, `gcp`, nullable) - `start` (query, date string, nullable) - `end` (query, date string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `by_spend` (object, required) - `total` (number, required) - `provider_tagged` (number, required) - `with_virtual` (number, required) - `provider_tagged_pct` (integer, required) - `with_virtual_pct` (integer, required) - `by_resource` (object, required) - `total` (number, required) - `provider_tagged` (number, required) - `with_virtual` (number, required) - `provider_tagged_pct` (integer, required) - `with_virtual_pct` (integer, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Values a condition can match Source: https://docs.levelfour.ai/api-reference/tags/get-tags-dimensions-by-id-values.md ## GET /api/v1/tags/dimensions/{dimension}/values ### Authentication Send `Authorization: Bearer `. ### Parameters - `dimension` (path, one of `account`, `billing_account`, `region`, `service`, `category`, `subcategory`, `usage_unit`, `resource`, `charge_type`, `marketplace`, `tag`, `tagged`, `virtual_tag`, required) - `provider` (query, one of `aws`, `gcp`, `digitalocean`, `all`, default `all`) - `key` (query, string, nullable) - `search` (query, string, nullable) - `limit` (query, integer, default `50`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `dimension` (one of `account`, `billing_account`, `region`, `service`, `category`, `subcategory`, `usage_unit`, `resource`, `charge_type`, `marketplace`, `tag`, `tagged`, `virtual_tag`, required) - `values` (array of string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Filter catalog for tag rules Source: https://docs.levelfour.ai/api-reference/tags/get-tags-dimensions.md ## GET /api/v1/tags/dimensions ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, one of `aws`, `gcp`, `digitalocean`, `all`, default `all`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `operators` (array of one of `is`, `is_not`, `contains`, `not_contains`, `starts_with`, `ends_with`, `flexible_match`, `not_flexible_match`, required) - `dimensions` (array of object, required) - `id` (one of `account`, `billing_account`, `region`, `service`, `category`, `subcategory`, `usage_unit`, `resource`, `charge_type`, `marketplace`, `tag`, `tagged`, `virtual_tag`, required) - `key_mode` (one of `required`, `optional`, `none`, required) - `valueless` (boolean, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `capabilities` (object, required) - `gcp_resource_level` (boolean, required) - `accounts_without_detailed_data` (array of string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Reprocessing job progress Source: https://docs.levelfour.ai/api-reference/tags/get-tags-jobs-by-id.md ## GET /api/v1/tags/jobs/{job_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `job_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Resources carrying a key Source: https://docs.levelfour.ai/api-reference/tags/get-tags-keys-by-id-resources.md ## GET /api/v1/tags/keys/{key_id}/resources ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) - `value_id` (query, string, nullable) - `provider` (query, one of `aws`, `gcp`, nullable) - `search` (query, string, nullable) - `sort_by` (query, one of `name`, `value`, `spend`, default `spend`) - `sort_order` (query, one of `asc`, `desc`, default `desc`) - `page` (query, integer, default `1`) - `page_size` (query, integer, default `50`) - `start` (query, date string, nullable) - `end` (query, date string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `resource_id` (string, required) - `name` (string, required) - `type` (string, required) - `provider` (one of `aws`, `gcp`, `digitalocean`, required) - `account_id` (string, required) - `value_id` (string, nullable) - `value_name` (string, nullable) - `value_source` (one of `provider`, `collapsed`, `config`, `allocation`, nullable) - `ratio` (number, default `1`) - `spend` (number, required) - `pagination` (object, required) - `total_items` (integer, required) - `total_pages` (integer, required) - `current_page` (integer, required) - `page_size` (integer, required) - `has_next` (boolean, required) - `has_previous` (boolean, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # One tag key Source: https://docs.levelfour.ai/api-reference/tags/get-tags-keys-by-id.md ## GET /api/v1/tags/keys/{key_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) - `start` (query, date string, nullable) - `end` (query, date string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `scope` (object, nullable) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `rules` (array of object) - `time_frame` (object, nullable) - `start` (date string, nullable) - `end` (date string, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List provider and virtual tag keys Source: https://docs.levelfour.ai/api-reference/tags/get-tags-keys.md ## GET /api/v1/tags/keys ### Authentication Send `Authorization: Bearer `. ### Parameters - `origin` (query, one of `virtual`, `provider`, nullable) - `provider` (query, one of `aws`, `gcp`, nullable) - `search` (query, string, nullable) - `start` (query, date string, nullable) - `end` (query, date string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `value_count` (integer, required) - `resource_count` (integer, required) - `spend` (number, required) - `spend_share_pct` (number, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `values` (array of object) - `id` (string, required) - `name` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Resources a condition can pin Source: https://docs.levelfour.ai/api-reference/tags/get-tags-resources.md ## GET /api/v1/tags/resources ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, one of `aws`, `gcp`, nullable) - `account_id` (query, string, nullable) - `search` (query, string, nullable) - `key` (query, string, nullable) - `page` (query, integer, default `1`) - `page_size` (query, integer, default `50`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `resource_id` (string, required) - `name` (string, required) - `type` (string, required) - `provider` (one of `aws`, `gcp`, `digitalocean`, required) - `account_id` (string, required) - `spend` (number, required) - `current_value_name` (string, nullable) - `pagination` (object, required) - `total_items` (integer, required) - `total_pages` (integer, required) - `current_page` (integer, required) - `page_size` (integer, required) - `has_next` (boolean, required) - `has_previous` (boolean, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Rename a virtual tag value Source: https://docs.levelfour.ai/api-reference/tags/patch-tags-virtual-by-id-values-by-id.md ## PATCH /api/v1/tags/virtual/{key_id}/values/{value_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) - `value_id` (path, string, required) ### Request body Required. Content type `application/json`. - `name` (string, required) - `expected_version` (integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Update virtual tag settings Source: https://docs.levelfour.ai/api-reference/tags/patch-tags-virtual-by-id.md ## PATCH /api/v1/tags/virtual/{key_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) ### Request body Required. Content type `application/json`. - `name` (string, nullable) - `description` (string, nullable) - `can_override` (boolean, nullable) - `effective_from` (string, nullable) - `expected_version` (integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Append a config Source: https://docs.levelfour.ai/api-reference/tags/post-tags-virtual-by-id-configs.md ## POST /api/v1/tags/virtual/{key_id}/configs ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) ### Request body Required. Content type `application/json`. - `filter` (object, required) - `rules` (array of object) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `conditions` (array of object) - `dimension` (one of `account`, `billing_account`, `region`, `service`, `category`, `subcategory`, `usage_unit`, `resource`, `charge_type`, `marketplace`, `tag`, `tagged`, `virtual_tag`, required) - `key` (string, nullable): Tag key for `tag`, `tagged` and `virtual_tag` conditions. - `operator` (one of `is`, `is_not`, `contains`, `not_contains`, `starts_with`, `ends_with`, `flexible_match`, `not_flexible_match`, default `is`) - `values` (array of string) - `time_frame` (object, nullable) - `start` (date string, nullable) - `end` (date string, nullable) - `assign` (object, required) - Variant 1: ValueAssign - `type` (`value`, required) - `value` (string, required) - Variant 2: PercentAssign - `type` (`percent`, required) - `splits` (array of object) - `value` (string, required) - `pct` (number, required) - Variant 3: CostBasedAssign - `type` (`cost_based`, required) - `input_filter` (object, required) - `rules` (array of object) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `expected_version` (integer, required) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Dry run a virtual tag definition Source: https://docs.levelfour.ai/api-reference/tags/post-tags-virtual-preview.md ## POST /api/v1/tags/virtual/preview ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `name` (string, default ``) - `description` (string, default ``) - `effective_from` (string, nullable): First billing month the tag applies to, 'YYYY-MM'. Null means the current month. - `can_override` (boolean, default `false`) - `collapsed_keys` (array of object) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `scope` (object, nullable) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `value_prefix` (string, default ``) - `configs` (array of object) - `filter` (object, required) - `rules` (array of object) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `conditions` (array of object) - `time_frame` (object, nullable) - `start` (date string, nullable) - `end` (date string, nullable) - `assign` (object, required) - Variant 1: ValueAssign - `type` (`value`, required) - `value` (string, required) - Variant 2: PercentAssign - `type` (`percent`, required) - `splits` (array of object) - Variant 3: CostBasedAssign - `type` (`cost_based`, required) - `input_filter` (object, required) - `source` (object, required) - `start` (date string, nullable) - `end` (date string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `shadowed_spend` (number, required) - `values` (array of object, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `sample_resources` (array of string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Create a virtual tag Source: https://docs.levelfour.ai/api-reference/tags/post-tags-virtual.md ## POST /api/v1/tags/virtual ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `name` (string, required) - `description` (string, default ``) - `effective_from` (string, nullable): First billing month the tag applies to, 'YYYY-MM'. Null means the current month. - `can_override` (boolean, default `false`) - `collapsed_keys` (array of object) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `scope` (object, nullable) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `value_prefix` (string, default ``) - `configs` (array of object) - `filter` (object, required) - `rules` (array of object) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `conditions` (array of object) - `time_frame` (object, nullable) - `start` (date string, nullable) - `end` (date string, nullable) - `assign` (object, required) - Variant 1: ValueAssign - `type` (`value`, required) - `value` (string, required) - Variant 2: PercentAssign - `type` (`percent`, required) - `splits` (array of object) - Variant 3: CostBasedAssign - `type` (`cost_based`, required) - `input_filter` (object, required) - `source` (object, required) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Replace a virtual tag's collapsed keys Source: https://docs.levelfour.ai/api-reference/tags/put-tags-virtual-by-id-collapsed-keys.md ## PUT /api/v1/tags/virtual/{key_id}/collapsed-keys ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) ### Request body Required. Content type `application/json`. - `collapsed_keys` (array of object, required) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `scope` (object, nullable) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `value_prefix` (string, default ``) - `expected_version` (integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Replace a config Source: https://docs.levelfour.ai/api-reference/tags/put-tags-virtual-by-id-configs-by-id.md ## PUT /api/v1/tags/virtual/{key_id}/configs/{config_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) - `config_id` (path, string, required) ### Request body Required. Content type `application/json`. - `filter` (object, required) - `rules` (array of object) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `conditions` (array of object) - `dimension` (one of `account`, `billing_account`, `region`, `service`, `category`, `subcategory`, `usage_unit`, `resource`, `charge_type`, `marketplace`, `tag`, `tagged`, `virtual_tag`, required) - `key` (string, nullable): Tag key for `tag`, `tagged` and `virtual_tag` conditions. - `operator` (one of `is`, `is_not`, `contains`, `not_contains`, `starts_with`, `ends_with`, `flexible_match`, `not_flexible_match`, default `is`) - `values` (array of string) - `time_frame` (object, nullable) - `start` (date string, nullable) - `end` (date string, nullable) - `assign` (object, required) - Variant 1: ValueAssign - `type` (`value`, required) - `value` (string, required) - Variant 2: PercentAssign - `type` (`percent`, required) - `splits` (array of object) - `value` (string, required) - `pct` (number, required) - Variant 3: CostBasedAssign - `type` (`cost_based`, required) - `input_filter` (object, required) - `rules` (array of object) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `expected_version` (integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Reorder configs Source: https://docs.levelfour.ai/api-reference/tags/put-tags-virtual-by-id-configs-order.md ## PUT /api/v1/tags/virtual/{key_id}/configs/order ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) ### Request body Required. Content type `application/json`. - `config_ids` (array of string, required) - `expected_version` (integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Replace a virtual tag Source: https://docs.levelfour.ai/api-reference/tags/put-tags-virtual-by-id.md ## PUT /api/v1/tags/virtual/{key_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `key_id` (path, string, required) ### Request body Required. Content type `application/json`. - `name` (string, required) - `description` (string, default ``) - `effective_from` (string, nullable): First billing month the tag applies to, 'YYYY-MM'. Null means the current month. - `can_override` (boolean, default `false`) - `collapsed_keys` (array of object) - `source` (object, required) - `origin` (one of `provider`, `virtual`, required) - `key` (string, required): A provider key name, or a virtual key id or name. - `scope` (object, nullable) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `value_prefix` (string, default ``) - `configs` (array of object) - `filter` (object, required) - `rules` (array of object) - `providers` (array of one of `aws`, `gcp`, `digitalocean`) - `conditions` (array of object) - `time_frame` (object, nullable) - `start` (date string, nullable) - `end` (date string, nullable) - `assign` (object, required) - Variant 1: ValueAssign - `type` (`value`, required) - `value` (string, required) - Variant 2: PercentAssign - `type` (`percent`, required) - `splits` (array of object) - Variant 3: CostBasedAssign - `type` (`cost_based`, required) - `input_filter` (object, required) - `source` (object, required) - `expected_version` (integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `key` (object, required) - `id` (string, required) - `name` (string, required) - `origin` (one of `virtual`, `provider`, required) - `description` (string, required) - `providers` (array of one of `aws`, `gcp`, `digitalocean`, required) - `status` (one of `active`, `reprocessing`, `error`, nullable) - `shares_provider_key` (boolean, default `false`) - `can_override` (boolean, default `false`) - `effective_from` (string, nullable) - `rules_version` (integer, default `0`) - `collapsed_keys` (array of object) - `source` (object, required) - `scope` (object, nullable) - `value_prefix` (string, default ``) - `position` (integer, required) - `configs` (array of object) - `filter` (object, required) - `time_frame` (object, nullable) - `assign` (object, required) - `id` (string, required) - `position` (integer, required) - `values` (array of object) - `id` (string, required) - `name` (string, required) - `spend` (number, required) - `resource_count` (integer, required) - `total_spend` (number, required) - `unallocated_spend` (number, required) - `resource_count` (integer, required) - `dependents` (array of object) - `id` (string, required) - `name` (string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) - `window` (object, required) - `start` (date string, required) - `end` (date string, required) - `job` (object, nullable) - `id` (string, required) - `status` (one of `queued`, `running`, `succeeded`, `failed`, `superseded`, required) - `from_period` (string, required) - `to_period` (string, required) - `periods_done` (integer, required) - `periods_total` (integer, required) - `error` (string, nullable) - `created_at` (date-time string, required) - `finished_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Cost Views Source: https://docs.levelfour.ai/api-reference/cost-views.md - [List cost views](/api-reference/cost-views/get-cost-views) - [Create a cost view](/api-reference/cost-views/post-cost-views) - [Read one cost view](/api-reference/cost-views/get-cost-views-by-id) - [Delete a cost view](/api-reference/cost-views/delete-cost-views-by-id) - [Replace a cost view](/api-reference/cost-views/put-cost-views-by-id) --- # Delete a cost view Source: https://docs.levelfour.ai/api-reference/cost-views/delete-cost-views-by-id.md ## DELETE /api/v1/cost-views/{view_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `view_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Read one cost view Source: https://docs.levelfour.ai/api-reference/cost-views/get-cost-views-by-id.md ## GET /api/v1/cost-views/{view_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `view_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `name` (string, required) - `providers` (array of string) - `filters` (map of string to array of string) - `filter_modes` (map of string to string) - `group_by` (array of string) - `show_cost_as` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `id` (string, required) - `created_by` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List cost views Source: https://docs.levelfour.ai/api-reference/cost-views/get-cost-views.md ## GET /api/v1/cost-views ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `name` (string, required) - `providers` (array of string) - `filters` (map of string to array of string) - `filter_modes` (map of string to string) - `group_by` (array of string) - `show_cost_as` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `id` (string, required) - `created_by` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Create a cost view Source: https://docs.levelfour.ai/api-reference/cost-views/post-cost-views.md ## POST /api/v1/cost-views ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `name` (string, required) - `providers` (array of string) - `filters` (map of string to array of string) - `filter_modes` (map of string to string) - `group_by` (array of string) - `show_cost_as` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `name` (string, required) - `providers` (array of string) - `filters` (map of string to array of string) - `filter_modes` (map of string to string) - `group_by` (array of string) - `show_cost_as` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `id` (string, required) - `created_by` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Replace a cost view Source: https://docs.levelfour.ai/api-reference/cost-views/put-cost-views-by-id.md ## PUT /api/v1/cost-views/{view_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `view_id` (path, string, required) ### Request body Required. Content type `application/json`. - `name` (string, required) - `providers` (array of string) - `filters` (map of string to array of string) - `filter_modes` (map of string to string) - `group_by` (array of string) - `show_cost_as` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `name` (string, required) - `providers` (array of string) - `filters` (map of string to array of string) - `filter_modes` (map of string to string) - `group_by` (array of string) - `show_cost_as` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `id` (string, required) - `created_by` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Recommendations Source: https://docs.levelfour.ai/api-reference/recommendations.md - [Get recommendations summary by provider](/api-reference/recommendations/get-recommendations-summary): Returns provider-level aggregation for the carousel view (AWS, GCP, Azure, K8s) - [Get potential savings summary by provider](/api-reference/recommendations/get-recommendations-potential-savings-summary): Returns potential savings summary grouped by provider - [Get recommendations breakdown with pagination](/api-reference/recommendations/get-recommendations): Returns paginated list of all recommendations with actions - [Get aggregate recommendations overview](/api-reference/recommendations/get-recommendations-overview): Returns recommendations overview metrics across all providers (for dashboard) - [Get processing recommendations](/api-reference/recommendations/get-recommendations-processing): Returns all recommendations currently in processing status - [List executions awaiting an admin's approval](/api-reference/recommendations/get-recommendations-pending-approvals): Recommendations a member has submitted for execution that need an admin's release. - [Get recommendation detail](/api-reference/recommendations/get-recommendations-by-id-details): Returns complete details for a specific recommendation - [Get Automated Savings permission state](/api-reference/recommendations/get-recommendations-by-id-automated-savings): Returns the Automated Savings managed-policy connection state and the Launch Stack URL - [Set the resources a saving applies to](/api-reference/recommendations/post-recommendations-by-id-resource-selection): Curates which resources in the recommendation the saving is limited to (the checked resources) - [Accept or reject savings](/api-reference/recommendations/post-recommendations-by-id-decision): Records user decision to accept or reject a savings recommendation - [Add rejection feedback](/api-reference/recommendations/post-recommendations-by-id-rejection-feedback): Backfill reason/explanation on an already-rejected recommendation - [Get recommendation activity timeline](/api-reference/recommendations/get-recommendations-by-id-activity): Return ordered audit events (views + decisions) for one recommendation - [Request execution approval (maker)](/api-reference/recommendations/post-recommendations-by-id-execution-request): A member requests execution; the recommendation moves to pending_approval and awaits an admin. --- # Get recommendation activity timeline Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-by-id-activity.md ## GET /api/v1/recommendations/{recommendation_id}/activity Return ordered audit events (views + decisions) for one recommendation ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Data returned for activity timeline - `recommendation_id` (string, required) - `events` (array of object, required) - `event_type` (string, required): viewed | terms_accepted | accepted | rejected | rejection_reason_added | implementation_method_set - `user_email` (string, nullable) - `org_id` (string, nullable) - `channel` (string, required): dashboard | cli | sdk-python | sdk-node | sdk-go | api | unknown - `occurred_at` (string, required) - `metadata` (object, nullable): Per-event context (decision, reason, method, etc) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get Automated Savings permission state Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-by-id-automated-savings.md ## GET /api/v1/recommendations/{recommendation_id}/automated-savings Returns the Automated Savings managed-policy connection state and the Launch Stack URL ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `permission_status` (one of `connected`, `pending`, `unknown`, default `unknown`): Whether the Automated Savings managed policy is attached to the cross-account role - `launch_stack_url` (string, nullable): CloudFormation quick-create URL that attaches the Automated Savings managed policy to the role - `template_version` (string, nullable): Automated Savings catalog template version #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get recommendation detail Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-by-id-details.md ## GET /api/v1/recommendations/{recommendation_id}/details Returns complete details for a specific recommendation ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required): Unique business identifier for the recommendation - `service` (string, required) - `resource_type` (string, nullable) - `account` (string, nullable) - `region` (string, nullable) - `environment` (string, nullable) - `tag` (string, nullable) - `analysis_period` (string, nullable) - `monthly_savings` (number, required) - `annual_savings` (number, required) - `current_spending` (number, required) - `savings_percentage` (number, required) - `actions` (object, nullable) - `description` (string, nullable): Brief summary of the recommendation - `overview` (string, nullable): Detailed overview explaining the recommendation (supports markdown) - `implementation_process` (string, nullable): How to implement this recommendation (supports markdown) - `execution_method` (string, nullable): Technical execution details (supports markdown) - `key_takeaway` (string, nullable): Important notes, risks, and safety information (supports markdown) - `operational_impact` (string, nullable): Impact on operations and any downtime requirements (supports markdown) - `status` (one of `pending`, `processing`, `in_progress`, `optimized`, `rejected`, `unavailable`, `failed`, default `pending`): Recommendation detail status - `display_status` (one of `available`, `awaiting_approval`, `pending`, `saved`, `rejected`, `unavailable`, default `available`): Status the person reads, served once so the dashboard, the CLI and MCP all print the same word. Derived from status, saving_acceptance and execution_request_status; a request waiting on an approver outranks everything else. Prefer this over status for display - `created_at` (string, nullable) - `tags` (array of string) - `resource_console_url` (string, nullable) - `implementation_tags` (array of string) - `comparison_data` (array of object) - `metrics` (array of object) - `risk_assessment` (object, nullable) - `implementation_steps` (array of object) - `api_pricing` (object, nullable) - `iam_role_arn` (string, nullable) - `iam_policy` (object, nullable) - `manual_instructions` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get aggregate recommendations overview Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-overview.md ## GET /api/v1/recommendations/overview Returns recommendations overview metrics across all providers (for dashboard) ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Recommendations overview metrics for a provider - `total_spend` (number, required): Total cost from last month's complete billing cycle - `display_savings` (map of string to number): Monthly savings keyed by display status, the same word every surface prints. The per-status *_savings fields below are keyed on the raw column, so a client had to fold processing into pending and optimized into saved for itself. Read this instead - `display_counts` (map of string to integer): Recommendation counts keyed by display status. Same relationship to the per-status *_count fields as display_savings has to *_savings - `available_savings` (number, required): Savings opportunities identified and available to execute - `pending_savings` (number, required): Savings opportunities pending required actions - `optimized_savings` (number, nullable): Monthly savings summed across all recommendations with status optimized (the Saved tab total) - `rejected_savings` (number, nullable): Monthly savings summed across all recommendations with status rejected - `unavailable_savings` (number, nullable): Monthly savings summed across all recommendations with status unavailable - `processing_savings` (number, nullable): Monthly savings summed across all recommendations with status processing - `available_count` (integer, nullable): Count of pending recommendations not yet accepted (the Available tab pill) - `pending_count` (integer, nullable): Count of pending recommendations already accepted (the Pending tab pill) - `processing_count` (integer, nullable): Count of recommendations with status processing - `optimized_count` (integer, nullable): Count of recommendations with status optimized (the Saved tab pill) - `rejected_count` (integer, nullable): Count of recommendations with status rejected - `unavailable_count` (integer, nullable): Count of recommendations with status unavailable - `total_count` (integer, nullable): True total of all recommendations for the provider (the All tab pill). Not the sum of the per-status counts: rollout states such as failed have no tab, so summing the buckets under-counts. Matches the universe the table paginates. - `saved_itd` (number, required): Total savings implemented inception to date - `awaiting_approval_count` (integer, default `0`): Count of recommendations awaiting an admin approval - `awaiting_approval_savings` (number, default `0`): Sum of monthly_savings for recommendations awaiting approval - `potential_savings` (number, nullable): Sum of all recommendations' monthly_savings - `evaluation_available_savings` (number, nullable): Sum of allowed evaluation IDs' monthly_savings - `saved_itd_delta_pct` (number, nullable): Percent change of saved_itd vs the prior period (30d) - `total_pipeline_delta_pct` (number, nullable): Percent change of the Total Pipeline (available + pending + saved_itd) vs the pipeline as it stood 30 days ago. Signed (positive means the pipeline grew), null when there was no prior pipeline to compare against - `saved_itd_history` (array of number): 30-point daily cumulative saved series for the sparkline - `top_recommendation` (object, nullable): The single highest-savings pending recommendation for this provider - `id` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_name` (string, nullable) - `savings_value` (number, required) - `top_recommendations` (array of object): Top open (pending) recommendations for this provider, ranked by monthly savings descending (max 3) - `id` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_name` (string, nullable) - `savings_value` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List executions awaiting an admin's approval Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-pending-approvals.md ## GET /api/v1/recommendations/pending-approvals Recommendations a member has submitted for execution that need an admin's release. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `recommendation_id` (string, required) - `service` (string, nullable) - `account` (string, nullable) - `region` (string, nullable) - `environment` (string, nullable) - `monthly_savings` (number, required) - `implementation_method` (string, nullable) - `requested_by` (string, nullable) - `requested_at` (date-time string, nullable) - `total` (integer, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get potential savings summary by provider Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-potential-savings-summary.md ## GET /api/v1/recommendations/potential-savings/summary Returns potential savings summary grouped by provider ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Summary data for all providers - `providers` (array of object, required) - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider_name` (string, required): Display name: AWS, GCP, etc. - `monthly_spending` (number, required): Current monthly spending for this provider - `potential_savings` (number, required): Total potential monthly savings - `potential_savings_percentage` (number, required): Savings as percentage of spending #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get processing recommendations Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-processing.md ## GET /api/v1/recommendations/processing Returns all recommendations currently in processing status ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Data for processing recommendations list - `items` (array of object, required) - `recommendation_id` (string, required): Unique business identifier - `service` (string, required): Service name (e.g., EC2, EBS, RDS) - `environment` (string, nullable): Environment (e.g., Production, Staging) - `monthly_savings` (number, required): Monthly savings amount - `annual_savings` (number, required): Annual savings amount - `implementation_method` (one of `one-click`, `iac`, `one-click-plus-iac`, `manual`, nullable): Implementation method - `status` (one of `pending`, `processing`, `in_progress`, `optimized`, `rejected`, `unavailable`, `failed`, required): Recommendation status - `completed_at` (string, nullable): When the wizard was completed - `count` (integer, required): Number of processing recommendations #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get recommendations summary by provider Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations-summary.md ## GET /api/v1/recommendations/summary Returns provider-level aggregation for the carousel view (AWS, GCP, Azure, K8s) ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Summary data for all providers - `providers` (array of object, required) - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider_name` (string, required): Display name: AWS, GCP, etc. - `monthly_spending` (number, required): Current monthly spending for this provider - `potential_savings` (number, required): Total potential monthly savings - `potential_savings_percentage` (number, required): Savings as percentage of spending #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get recommendations breakdown with pagination Source: https://docs.levelfour.ai/api-reference/recommendations/get-recommendations.md ## GET /api/v1/recommendations Returns paginated list of all recommendations with actions ### Authentication Send `Authorization: Bearer `. ### Parameters - `page` (query, integer, default `1`): Page number (1-indexed) - `page_size` (query, integer, default `20`): Items per page (max 200) - `sort_by` (query, string, default `monthly_savings`): Field to sort by (recommendation_id, service, environment, monthly_savings, annual_savings, savings_percentage, analysis_period, created_at) - `sort_order` (query, one of `asc`, `desc`, default `desc`): Sort order ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Recommendations breakdown data with pagination - `items` (array of object, required) - `recommendation_id` (string, required): Unique business identifier for the recommendation - `service` (string, required) - `environment` (string, nullable) - `analysis_period` (string, nullable) - `monthly_savings` (number, required) - `annual_savings` (number, required) - `current_spending` (number, required) - `savings_percentage` (number, required) - `status` (string, nullable): Display status. 'unavailable' means the recommendation is outside the tenant's evaluation allowlist, so its implementation detail is withheld - `display_status` (one of `available`, `awaiting_approval`, `pending`, `saved`, `rejected`, `unavailable`, default `available`): Status the person reads, served once so the dashboard, the CLI and MCP all print the same word. Derived from status, saving_acceptance and execution_request_status; a request waiting on an approver outranks everything else. Prefer this over status for display - `actions` (object, nullable) - `description` (string, nullable): Brief summary of the recommendation - `overview` (string, nullable): Detailed overview explaining the recommendation (supports markdown) - `implementation_process` (string, nullable): How to implement this recommendation (supports markdown) - `execution_method` (string, nullable): Technical execution details (supports markdown) - `key_takeaway` (string, nullable): Important notes, risks, and safety information (supports markdown) - `operational_impact` (string, nullable): Impact on operations and any downtime requirements (supports markdown) - `risk_assessment` (object, nullable): Risk level denormalized from actions so the list/table Risk column renders (level only; full factors live on the detail endpoint) - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Accept or reject savings Source: https://docs.levelfour.ai/api-reference/recommendations/post-recommendations-by-id-decision.md ## POST /api/v1/recommendations/{recommendation_id}/decision Records user decision to accept or reject a savings recommendation ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Request body Required. Content type `application/json`. - `decision` (string, required): Decision: 'accepted' or 'rejected' - `reason` (string, nullable): Rejection reason (optional): operational, strategy, not_applicable, other - `explanation` (string, nullable): Free text explanation (optional, used when reason is 'other') ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Data returned after making a decision - `recommendation_id` (string, required) - `saving_acceptance` (string, required) - `saving_accepted_by` (string, required) - `saving_accepted_at` (string, required) - `rejection_reason` (string, nullable) - `rejection_explanation` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Request execution approval (maker) Source: https://docs.levelfour.ai/api-reference/recommendations/post-recommendations-by-id-execution-request.md ## POST /api/v1/recommendations/{recommendation_id}/execution/request A member requests execution; the recommendation moves to pending_approval and awaits an admin. ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Request body Required. Content type `application/json`. - `implementation_method` (string, default `one-click`): Implementation method to execute with ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): State of an execution-approval (maker-checker) transition. - `recommendation_id` (string, required) - `execution_request_status` (string, required) - `execution_request_by` (string, nullable) - `execution_approved_by` (string, nullable) - `status` (string, nullable) - `rejection_reason` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Add rejection feedback Source: https://docs.levelfour.ai/api-reference/recommendations/post-recommendations-by-id-rejection-feedback.md ## POST /api/v1/recommendations/{recommendation_id}/rejection-feedback Backfill reason/explanation on an already-rejected recommendation ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Request body Required. Content type `application/json`. - `reason` (string, required): Rejection reason: operational, strategy, not_applicable, other - `explanation` (string, nullable): Free text explanation (optional) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Data returned after adding rejection feedback - `recommendation_id` (string, required) - `saving_acceptance` (string, required) - `rejection_reason` (string, required) - `rejection_explanation` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Set the resources a saving applies to Source: https://docs.levelfour.ai/api-reference/recommendations/post-recommendations-by-id-resource-selection.md ## POST /api/v1/recommendations/{recommendation_id}/resource-selection Curates which resources in the recommendation the saving is limited to (the checked resources) ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Request body Required. Content type `application/json`. - `selected_resources` (array of string, required): Resource identifiers to keep (the checked resources). Any resource not listed is removed from the recommendation. Must be a non-empty subset of the current resource_breakdown. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `selected_resources` (array of string, required): The resource ids that remain after the prune #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Savings Grants Source: https://docs.levelfour.ai/api-reference/savings-grants.md - [Create a per-batch scoped, time-boxed savings grant](/api-reference/savings-grants/post-recommendations-grants) - [Poll a savings grant status](/api-reference/savings-grants/get-recommendations-grants-by-id) --- # Poll a savings grant status Source: https://docs.levelfour.ai/api-reference/savings-grants/get-recommendations-grants-by-id.md ## GET /api/v1/recommendations/grants/{grant_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `grant_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `grant_id` (string, required) - `batch_parent_id` (string, required) - `provider` (one of `aws`, `gcp`, required) - `status` (one of `pending`, `verifying`, `connected`, `failed`, `expired`, required) - `error_message` (string, nullable): Populated when status is failed - `polling_hint_ms` (integer, required): How long the dashboard should wait before polling again; 0 when terminal - `expires_at` (date-time string, required) - `scope_summary` (object, required) - `action_count` (integer, default `0`): Distinct IAM actions in the merged grant - `resource_count` (integer, default `0`): Distinct resources the grant scopes to - `policy_group_count` (integer, default `0`): Number of managed policies the grant attaches to the role - `child_count` (integer, default `0`): Number of recommendations covered by this batch grant - `launch_stack_url` (string, nullable): AWS Console quickcreate URL that attaches the scoped, time-boxed grant. Re-issued on every read so a grant stays launchable after the create response, and null once it has settled #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Create a per-batch scoped, time-boxed savings grant Source: https://docs.levelfour.ai/api-reference/savings-grants/post-recommendations-grants.md ## POST /api/v1/recommendations/grants ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `batch_parent_id` (string, required): recommendation_id to grant scoped access for. A batch parent grants the merged policy of its children; a single recommendation grants its own policy. ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `grant_id` (string, required): UUID minted per apply; correlates the pingback to the dashboard session - `launch_stack_url` (string, nullable): AWS Console quickcreate URL that attaches the scoped, time-boxed grant - `connect_command` (string, nullable): GCP Cloud Shell command the customer runs to apply the scoped, time-boxed grant. The per-grant bearer secret travels in the Authorization header, not the URL. - `expires_at` (date-time string, required): When the grant expires (DateLessThan baked into every statement) - `scope_summary` (object, required) - `action_count` (integer, default `0`): Distinct IAM actions in the merged grant - `resource_count` (integer, default `0`): Distinct resources the grant scopes to - `policy_group_count` (integer, default `0`): Number of managed policies the grant attaches to the role - `child_count` (integer, default `0`): Number of recommendations covered by this batch grant - `stale_grant_policies` (array of string): For a commitment, the LevelFourSavingsGrant policies still attached to the execution role whose grants have expired. AWS allows 10 managed policies on a role by default, so their stacks are deleted first #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Repo Binding Source: https://docs.levelfour.ai/api-reference/repo-binding.md - [List GitHub App installations for the tenant](/api-reference/repo-binding/get-repo-binding-installations) - [List repositories accessible by an installation](/api-reference/repo-binding/get-repo-binding-installations-by-id-repos) - [List repositories bound to a recommendation](/api-reference/repo-binding/get-repo-binding-recommendations-by-id-targets) - [Bind repositories to a recommendation](/api-reference/repo-binding/post-repo-binding-recommendations-by-id-targets) - [Unbind a repository from a recommendation](/api-reference/repo-binding/delete-repo-binding-recommendations-by-id-targets) --- # Unbind a repository from a recommendation Source: https://docs.levelfour.ai/api-reference/repo-binding/delete-repo-binding-recommendations-by-id-targets.md ## DELETE /api/v1/repo-binding/recommendations/{recommendation_id}/targets ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) - `repo_full_name` (query, string, required): Full name (owner/name) of the repository to unbind ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `items` (array of object, required) - `repo_full_name` (string, required): Full name (owner/name) of the bound repository - `installation_id` (integer, required): GitHub App installation id for this repository - `pr_url` (string, nullable) - `pr_number` (integer, nullable) - `pr_status` (string, nullable) - `opened_at` (date-time string, nullable) - `error_message` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List repositories accessible by an installation Source: https://docs.levelfour.ai/api-reference/repo-binding/get-repo-binding-installations-by-id-repos.md ## GET /api/v1/repo-binding/installations/{installation_id}/repos ### Authentication Send `Authorization: Bearer `. ### Parameters - `installation_id` (path, integer, required) - `query` (query, string, nullable): Case-insensitive substring of the repository name ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `installation_id` (integer, required) - `repos` (array of string) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List GitHub App installations for the tenant Source: https://docs.levelfour.ai/api-reference/repo-binding/get-repo-binding-installations.md ## GET /api/v1/repo-binding/installations ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `installation_id` (integer, required) - `account_login` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List repositories bound to a recommendation Source: https://docs.levelfour.ai/api-reference/repo-binding/get-repo-binding-recommendations-by-id-targets.md ## GET /api/v1/repo-binding/recommendations/{recommendation_id}/targets ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `items` (array of object, required) - `repo_full_name` (string, required): Full name (owner/name) of the bound repository - `installation_id` (integer, required): GitHub App installation id for this repository - `pr_url` (string, nullable) - `pr_number` (integer, nullable) - `pr_status` (string, nullable) - `opened_at` (date-time string, nullable) - `error_message` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Bind repositories to a recommendation Source: https://docs.levelfour.ai/api-reference/repo-binding/post-repo-binding-recommendations-by-id-targets.md ## POST /api/v1/repo-binding/recommendations/{recommendation_id}/targets ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Request body Required. Content type `application/json`. - `installation_id` (integer, required): GitHub App installation id that grants access to the repos - `repos` (array of string, required): Full names (owner/name) to bind to this recommendation ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `items` (array of object, required) - `repo_full_name` (string, required): Full name (owner/name) of the bound repository - `installation_id` (integer, required): GitHub App installation id for this repository - `pr_url` (string, nullable) - `pr_number` (integer, nullable) - `pr_status` (string, nullable) - `opened_at` (date-time string, nullable) - `error_message` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Audit Source: https://docs.levelfour.ai/api-reference/audit.md - [Get Savings Audit summary](/api-reference/audit/get-recommendations-audit-summary): Returns enhanced BigNumbers data for the Savings Audit page - [Get saved recommendations breakdown with pagination](/api-reference/audit/get-recommendations-audit-breakdown): Returns paginated list of all implemented savings with filtering - [Get realized savings audit detail](/api-reference/audit/get-recommendations-audit-realized-by-id): Returns the parent realized_savings_audit row plus all per-usage_type line items. Powers the Savings Audit drill-down side panel. --- # Get saved recommendations breakdown with pagination Source: https://docs.levelfour.ai/api-reference/audit/get-recommendations-audit-breakdown.md ## GET /api/v1/recommendations/audit/breakdown Returns paginated list of all implemented savings with filtering ### Authentication Send `Authorization: Bearer `. ### Parameters - `page` (query, integer, default `1`): Page number (1-indexed) - `page_size` (query, integer, default `10`): Items per page (max 200) - `sort_by` (query, string, default `period`): Field to sort by (period, account_id, environment, service, usage_type, usage_quantity, usage_unit, pre_optimization_cost, post_optimization_cost, monthly_savings, roi, approved_by) - `sort_order` (query, one of `asc`, `desc`, default `desc`): Sort order - `start` (query, string, nullable): Start date filter (YYYY-MM-DD or YYYY-MM format) - `end` (query, string, nullable): End date filter (YYYY-MM-DD or YYYY-MM format) - `preset` (query, string, nullable): Date preset: '30D', '6M', or '12M' (takes precedence over start/end) - `provider` (query, string, nullable): Filter by provider ID (aws, gcp, azure, k8s) - `service` (query, array of string, nullable): Filter by service(s) - can be repeated for multiple values - `environment` (query, array of string, nullable): Filter by environment(s) - can be repeated for multiple values - `account_id` (query, array of string, nullable): Filter by account ID(s) - can be repeated for multiple values ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Saved breakdown data with pagination - `total_savings` (number, required): Sum of monthly_savings across all filtered results - `items` (array of object, required) - `audit_id` (integer, required): Realized savings audit row id (use to fetch /audit/realized/{audit_id}) - `recommendation_id` (string, required): Recommendation that produced this saving - `savings_id` (string, required): User-facing savings identifier (alias of recommendation_id) - `period` (string, required) - `implemented_at` (string, nullable): ISO 8601 timestamp of the cloud event that realized the savings - `account_id` (string, nullable) - `environment` (string, nullable) - `service` (string, required) - `usage_type` (string, nullable): Usage type description; null when no Cost Explorer line item is available - `usage_quantity` (number, nullable): Usage quantity; null when no Cost Explorer line item is available - `usage_unit` (string, nullable): Usage unit; null when no Cost Explorer line item is available - `approved_by` (string, nullable): Dashboard user who approved the savings (saving_accepted_by); falls back to actor_principal - `applied_via` (string, required): How the apply was attributed (sourced from implementation_method) - `implementation_method` (string, nullable): How the rec was implemented: one-click / one-click-plus-iac / iac / manual - `first_view_at` (string, nullable): Earliest view of this rec on any channel (manual path) - `first_view_channel` (string, nullable): Channel of the earliest view: dashboard / cli / sdk-go / sdk-node / sdk-python / api - `view_count_at_audit` (integer, nullable): Total view count snapshot at audit reconcile time - `verification` (string, default `pending`): Verification state: pending / auto-by-worker / auto-by-worker-and-iac / auto-by-iac / auto-wizard-manual / manual-verified / manual-unverified - `estimated_savings_usd` (number, nullable): Estimated monthly savings snapshot at apply time (from recommendation) - `realized_savings_usd` (number, nullable): Settled realized savings; null while the billing period is open or before settlement - `realized_savings_in_progress_usd` (number, nullable): Running CE-visible realized savings during an open billing period - `settled_at` (string, nullable): Timestamp at which realized_savings_usd was finalized for this period - `realized_savings_7d_usd` (number, nullable): Reconciliation snapshot 7 days post-apply (CE-driven) - `realized_savings_30d_usd` (number, nullable): Reconciliation snapshot 30 days post-apply (CE-driven, billable) - `snapshot_7d_at` (string, nullable): Timestamp the 7-day snapshot was taken - `snapshot_30d_at` (string, nullable): Timestamp the 30-day snapshot was taken - `realized_basis` (string, required): Source for realized savings: cost-explorer, pro-rated-estimate, ... - `pre_optimization_cost` (number, required) - `post_optimization_cost` (number, required) - `monthly_savings` (number, required) - `annual_savings` (number, required) - `roi` (number, required) - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get realized savings audit detail Source: https://docs.levelfour.ai/api-reference/audit/get-recommendations-audit-realized-by-id.md ## GET /api/v1/recommendations/audit/realized/{audit_id} Returns the parent realized_savings_audit row plus all per-usage_type line items. Powers the Savings Audit drill-down side panel. ### Authentication Send `Authorization: Bearer `. ### Parameters - `audit_id` (path, integer, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Full realized-savings audit row with line-item breakdown - `id` (integer, required) - `savings_id` (string, required): User-facing savings identifier (alias of recommendation_id) - `recommendation_id` (string, required) - `approved_by` (string, nullable): Dashboard user who approved the savings (saving_accepted_by); falls back to actor_principal - `applied_via` (string, required): How the apply was attributed (sourced from implementation_method) - `implementation_method` (string, nullable): How the rec was implemented: one-click / one-click-plus-iac / iac / manual - `first_view_at` (string, nullable): Earliest view of this rec on any channel (manual path) - `first_view_channel` (string, nullable): Channel of the earliest view: dashboard / cli / sdk-go / sdk-node / sdk-python / api - `view_count_at_audit` (integer, nullable): Total view count snapshot at audit reconcile time - `verification` (string, default `pending`): Verification state: pending / auto-by-worker / auto-by-worker-and-iac / auto-by-iac / auto-wizard-manual / manual-verified / manual-unverified - `technical_details` (object, nullable): Forensic CloudTrail evidence; populated only for manual-verified / manual-unverified rows - `cloud_event_id` (string, required) - `cloud_event_name` (string, required) - `cloud_event_time` (string, nullable) - `actor_principal` (string, nullable) - `actor_type` (string, nullable) - `actor_ip` (string, nullable) - `evidence` (object) - `implemented_at` (string, nullable): ISO 8601 timestamp of the cloud event that realized the savings - `execution_logs` (array of object): Execution log steps from dashboard_potential_savings.actions (apply trace, FINAL SUMMARY) - `execution_steps` (array of object): Execution step plan from dashboard_potential_savings.actions - `provider` (string, required) - `cloud_event_id` (string, required) - `cloud_event_name` (string, required) - `cloud_event_time` (string, nullable) - `actor_principal` (string, nullable) - `actor_type` (string, nullable) - `actor_ip` (string, nullable) - `cloud_resource_id` (string, required) - `service` (string, required) - `account_id` (string, required) - `region` (string, nullable) - `environment` (string, nullable) - `dashboard_viewer` (string, nullable) - `dashboard_view_id` (integer, nullable) - `billing_period` (string, required) - `billing_period_start` (string, nullable) - `billing_period_end` (string, nullable) - `days_active_in_period` (integer, required) - `estimated_savings_usd` (number, nullable) - `realized_savings_usd` (number, nullable) - `realized_savings_in_progress_usd` (number, nullable) - `settled_at` (string, nullable) - `realized_savings_7d_usd` (number, nullable) - `realized_savings_30d_usd` (number, nullable) - `snapshot_7d_at` (string, nullable) - `snapshot_30d_at` (string, nullable) - `realized_basis` (string, required) - `calculator_version` (string, required) - `frozen_at` (string, nullable) - `evidence` (object) - `detected_at` (string, nullable) - `updated_at` (string, nullable) - `lines` (array of object) - `id` (integer, required) - `usage_type` (string, required) - `usage_quantity` (number, required) - `usage_unit` (string, required) - `unit_price_usd` (number, nullable) - `savings_usd` (number, required) - `operation` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get Savings Audit summary Source: https://docs.levelfour.ai/api-reference/audit/get-recommendations-audit-summary.md ## GET /api/v1/recommendations/audit/summary Returns enhanced BigNumbers data for the Savings Audit page ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): Savings Audit summary data - simplified for UI BigNumbers - `total_monthly_savings` (number, required): Combined monthly savings run rate across every implemented recommendation. Each ledger row carries a recommendation's monthly rate once, in the month it was applied, so this spans several billing periods and is still a monthly figure. - `total_monthly_savings_percentage` (number, nullable): Change in the monthly run rate from the previous billing period to the latest one - `total_annual_savings` (number, required): The monthly run rate multiplied by 12 - `payback` (number, nullable): Payback period in months - `average_roi` (number, required): Average ROI percentage - `total_savings` (number, required): Total savings annualized, the figure LevelFour bills against. Equal to total_annual_savings. Not an inception-to-date accumulation, and note that providers[].total_savings is the monthly run rate rather than an annual figure. - `providers` (array of object): Per-provider savings breakdown for filtering - `provider_id` (string, required): Provider identifier: aws, gcp, azure, k8s - `provider` (string, required): Display name: AWS, GCP, etc. - `total_savings` (number, required): Total monthly savings for this provider - `by_implementation_method` (map of string to number): Realized savings grouped by implementation_method (one-click, one-click-plus-iac, iac, manual) - `by_module` (array of object): Open savings opportunity grouped by module ('cloud', 'commitments') - `id` (string, required): Module identifier: 'cloud' or 'commitments' - `monthly_savings` (number, required): Monthly savings attributed to this module - `open_recommendations` (integer, required): Count of pending recommendations in this module #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Commitments Source: https://docs.levelfour.ai/api-reference/commitments.md - [Get commitments overview](/api-reference/commitments/get-commitments-overview): ESR strip and aggregate RI/SP metrics for the requested provider (AWS real, GCP/Azure return zeros). - [How much of the eligible bill a commitment covers](/api-reference/commitments/get-commitments-coverage-rates): Coverage per service and the covered spend per consuming account, with the on-demand remainder beside it so the percentage names its own denominator. Weighted by the dollars behind each figure, never averaged across commitments, where a hundred dollar reservation would move the number as far as a fifty thousand dollar one. A provider whose sweep has not run reports measured false rather than zero coverage, because holding none and not having looked are opposite answers. months adds that many calendar months ending at the current month, AWS only, for the organization, each payer and each account where the workloads run: instance hours at public on-demand prices covered and uncovered, and the commitment dollars each account consumed, took from other accounts, gave to them and left idle. On Google Cloud, projects lists each project's committed use discounts month to date, and cost_export_cud_status says whether the billing export has been swept. - [Get coverage rows](/api-reference/commitments/get-commitments-coverage): Deprecated, use /coverage-rates. This reads one row per commitment held, so usage no commitment covers has no row and cannot appear, which is the larger half of coverage. on_demand_cost_monthly, saved_vs_od_monthly and filtered_savings_monthly are not measured here and are sent as zero, and filtered_coverage_pct averages percentages across commitments rather than weighting them by spend. - [Get coverage filter options](/api-reference/commitments/get-commitments-coverage-filter-options) - [One row per commitment](/api-reference/commitments/get-commitments-inventory): Every reservation or plan the tenant holds, one row each, with the state the cloud reports rather than one derived from the end date. A plan that starts next month is queued and one whose payment failed is neither running nor expired, and no date says either. Attributes only some services carry arrive as null rather than zero. - [Utilization for one instrument, optionally for one service](/api-reference/commitments/get-commitments-utilization): One entry per service for reservations, or per plan type for plans, each measured on its own and never rolled up: EC2 and RDS contract in vCPU while Redshift, ElastiCache, OpenSearch, Elasticsearch and MemoryDB contract in nodes, so an average across them describes neither. Each entry's headline divides the used hours by the purchased hours over the window rather than averaging its dimensions. - [List commitments](/api-reference/commitments/get-commitments) - [What replacing this commitment costs today](/api-reference/commitments/get-commitments-renewal-quote-by-id): Priced from the current offering rather than from the expiring term, whose price is as old as the term. error_message carries a lookup that failed, so an unpriced renewal reads as unpriced rather than as zero. - [Every term this commitment could be replaced with](/api-reference/commitments/get-commitments-renewal-options-by-id): The contract priced again as it stands, beside every other term its shape is sold on today, each priced at up to about forty quantities spanning what was sized and what is held, and recommended on exactly one row, above the quantity held only on a family basis, or on none when decision is let_expire: the commitment was used too little to pay for itself, so every row is priced at the quantity held for comparison and nothing is advised. With quantity, each offering is priced at exactly that quantity instead, one row each; a reservation is sold in whole units, so a fractional quantity is a 422. Without a stored series every quantity is still priced, and idle_units, spill_units and spill_cost come back null. sizing_basis says where the recommended quantity came from: the commitment's own fee line, which can only argue for buying less; family, that line plus the on-demand usage of its family and engine held on nine days in ten, the one basis that can argue for more; or unmeasured, where the quantity held stands; measured appears only on options an earlier sweep priced. upfront is the total paid at purchase for the row's quantity; recurring_hourly is the rate per unit. demand is the daily series the sizing read, with its projection across the longest term. A size-flexible RDS, ElastiCache or MemoryDB reservation can be renewed as a larger class of its family: classes lists every class it was priced in, smallest first, with what one reservation covers in the class held and the term and payment options each is sold on. The rows are one class, the one recommended unless resource names another; resource on a row is the class bought, every quantity and demand are counted in it, held_units_equivalent is a row's quantity in the class held, and the contract is always priced in the class held. A class it was not priced in is a 422. quota says how many reservations the account may hold against what it would hold once the expiring ones are renewed; quota_blocked marks a renewal it could not hold in any class. size_mix is what the family runs by instance size and deployment. - [The renewal purchase already raised for this commitment](/api-reference/commitments/get-commitments-renewal-by-id) - [Raise the purchase that replaces this commitment](/api-reference/commitments/post-commitments-renewal-by-id): Returns the recommendation that buys the new term, and the offering and quantity it is bound to. Calling it again never raises a rival: with no choice it returns the first one, and with a different choice it points a renewal nobody has decided on at that choice (rebound). rebindable says whether what it buys can still change: once anybody accepts, rejects or requests it, only the choice it was decided on is accepted. A renewal rejected before anybody released it is closed, and raising again creates a new one while the rejected one stays as history. Nothing is bought here: the recommendation carries the offering, the quantity and the instant after which buying is safe, and the apply path acts on it. A commitment already renewed, or whose family and engine ran nothing last week, is refused with 409. A read-write API key may raise a renewal; releasing it needs an organization admin. - [Where the purchase renewing this commitment stands](/api-reference/commitments/get-commitments-execution-by-id): The latest purchase run of this commitment's renewal, with the steps, logs and trigger of the recommendation that renews it. not_started until somebody releases the renewal. queued once released and waiting for the term to end, or once the provider queued the purchase. in_progress while a run holds it. active once the provider reports the new commitment active, or once the renewal was completed by hand. payment_failed when the provider could not charge for it. failed when a run was refused before sending or the provider refused the purchase. indeterminate when nobody knows yet whether the purchase went through: it is reconciled, never retried. A commitment the organization does not hold is a 404. - [Get commitment detail](/api-reference/commitments/get-commitments-detail-by-id) - [Per-service commitment breakdown](/api-reference/commitments/get-commitments-by-service): Returns a service x instrument matrix of RI and SP utilization, coverage, unused, and uncovered values. One row per service that has at least one commitment. - [Get commitment recommendations](/api-reference/commitments/get-commitments-recommendations) - [Commitment portfolio ledger](/api-reference/commitments/get-commitments-portfolio): One row per commitment across every connected account, resolving the account that holds it against the accounts that consume it, with the expiry instant, the utilization trend, and what a renewal protects kept separate from what right-sizing would save. - [Effective savings rate, globally and per account](/api-reference/commitments/get-commitments-esr): What the same usage would have cost at public on-demand rates against what it actually cost, for the organization and for each account that consumed the benefit. The rate is split into the part a negotiated agreement earns and the part commitments earn, because the public on-demand price sits above both and an account owning no commitment at all would otherwise report a healthy rate. esr_pct is the rate after idle commitment, and is null until the month is complete. months adds that many calendar months ending at period, for the organization, each payer and each account. - [Uncovered on-demand base, split for sizing](/api-reference/commitments/get-commitments-uncovered): On-demand spend a Savings Plan could cover and no commitment does, per payer, plan type, service and platform, read off the daily CUR over the last 30 complete days. Carries the daily floor of each slice, in commitment dollars an hour, and names each row's grain and source. A payer with no CUR gets Cost Explorer's hourly figures as visibility, with no suggested size. - [Savings Plans and Reserved Instances to buy, sized on the daily bill](/api-reference/commitments/get-commitments-purchase-plan): The uncovered base, as /uncovered returns it, beside one proposal per payer and plan type at 12 months No Upfront. Each proposal offers three profiles sized on the daily CUR floor, net of the commitment already bought or agreed, capped by AWS's own recommendation, and replayed over the last 60 complete days. Free to every organization; reads cached rates and offerings and never calls AWS. raised is null until a proposal can be raised as a purchase. schedule lists every tranche of each purchase schedule with a month still to come: its day, its size, where it stands and why it last shrank or was skipped. reservations lists the new Reserved Instances LevelFour would buy for on-demand usage no Savings Plan covers, each sized on the 10th percentile day, quoted on the cached offering at the guardrails' term, and flagged quota_blocked when the RDS reserved instance quota cannot hold it. - [What one Savings Plan commitment would have done](/api-reference/commitments/get-commitments-purchase-simulation): Replays a commitment in dollars an hour over the payer's recent daily bill: the eligible on-demand spend it would have covered, the commitment it would have left unused, its utilization, coverage and monthly savings, day by day, beside the sized profiles and AWS's own figure. A day averages its hours, so every figure is an upper bound on the hourly truth. Free to every organization; never calls AWS. A Database plan is sold for 12 months No Upfront only. payer_account_id may be omitted when the organization has one payer. Missing data answers 200 with unavailable_reason. - [What a new reservation or Savings Plan would cost and save](/api-reference/commitments/post-commitments-purchase-simulation): Quotes one purchase the organization does not hold yet, a NewPurchase with an optional lookback_days of 30 or 60. basis is usage when the buying account's payer already runs the target: a reservation is replayed over its family's daily on-demand hours in units of the class bought and priced at the on-demand rate measured on the bill; a plan is replayed at the organization's own rates, as GET /purchase-simulation does, on any term and payment AWS sells it on. Otherwise basis is full_use: every hour used, against the price list's on-demand rate where it has one, and at face (no saving) where it has none, which is always the case for a plan. upfront, recurring_monthly, commitment_monthly and term_total are the offering's list price; over_automation_cap says Automated Savings would refuse the term total. A reservation quoted on usage also answers unused_units and left_on_demand_units, what it leaves unused and on demand an hour in reservations of the class bought. The offering is the one named, or the cheapest sold on those terms. A plan quoted without an offering_id is priced on its commitment, term and payment option without reading the catalogue, and its offering_id is null. 422 when AWS sells nothing on those terms, and for a Database plan other than 12 months No Upfront; 502 when every connected account refused the offerings lookup. Buys nothing. - [What AWS sells for a reservation or Savings Plan target](/api-reference/commitments/get-commitments-offerings): The catalogue one field at a time. choices lists, for every field of the target, the values sold given every field before it, in the order each narrows the next: service, region, engine, deployment, offering_class, tenancy and resource for a reservation, plan_type, instance_family and region for a plan. A field the service does not use has no choices and must be left out. complete says the target is whole; offers then lists every offering sold for it, priced per unit (a reservation, or a dollar an hour of a plan) beside the public on-demand rate where the price list has one. EC2 is read one instance type at a time, so its offers wait for resource. Regions are where the organization's accounts live, hold commitments, run anything or opted in; classes are those it runs and those the catalogue sells. Every page AWS returns is kept a day, fetched_at says when it was read. AWS is read through the role of the optional buying_account_id, the account the purchase would be bought in, then its payer's, then every other connected account's, payers first, passing over a role AWS refuses to assume. 422 for a value the service does not sell; 502 when every connected account refused the offerings lookup. - [Where a Savings Plan purchase stands](/api-reference/commitments/get-commitments-purchase-by-id): One BUY- purchase: what it buys, the tranche it belongs to, and its latest purchase run with the steps, logs and trigger of its row. execution.status reads as it does for a renewal, plus returned when AWS refunded the plan after it was bought, and cancelled when a plan queued inside AWS was deleted while queued. execution.starts_at is when AWS starts such a plan: only runs sent before LevelFour stopped queueing inside AWS carry either. bought_outside names the plan of the same type and term bought outside LevelFour that stopped the latest run. execution.status reads scheduled while a released purchase waits on LevelFour's side until execution.send_at, and cancelled_before_purchase once it was cancelled before it was sent. execution.can_buy_now and execution.can_cancel say whether the caller may buy it now or cancel it at this moment, by the rules those two routes apply. Reads stored state only and never calls AWS. 404 for an id that is no purchase of this organization. - [Re-size a purchase nobody has decided yet](/api-reference/commitments/patch-commitments-purchase-by-id): A body naming kind re-sizes a purchase raised as a NewPurchase: its size, term, payment option and offering, looked up again in AWS. Its target and buying account stay as raised (409 for another). A body without kind points a purchase raised from its proposal at another profile or commitment_hourly, sized on today's proposal; naming neither re-sizes the pick it was raised on. Each body fits only its own kind of purchase (409 otherwise). Keeps the id. 404 for an id that is no purchase, 409 once anybody has accepted, rejected or requested it, and 409 for any refusal a raise meets. Answers rebound true. A draft is re-sized by the person who raised it alone (404 for anybody else), which renews its expiry, and 409 purchase_draft_expired once it has expired. - [Raise a Reserved Instance or Savings Plan purchase](/api-reference/commitments/post-commitments-purchase): Raises a purchase as a savings recommendation, BUY- followed by a number. Nothing is bought until an organization admin releases it, so a member may raise one, and so may a read-write API key. A body naming kind is a NewPurchase: a reservation (service, region, class, quantity) or a Savings Plan of any type (commitment_hourly, and for an EC2 Instance plan its instance_family and region), on a 12 or 36 month term and any payment option. offering_id is required: it is looked up again in AWS and refused with 409 once AWS no longer sells it. buying_account_id must be an AWS account of the organization (422 otherwise). monthly_savings is the quote's net savings when it was replayed over measured usage, and 0 when it assumes every hour is used. Calling it again for the same target returns the undecided purchase when it names the same pick. Any other pick is answered 409 undecided_purchase_exists with that purchase's recommendation_id, state, raised_by and raised_at, and confirm_additional true raises a second one beside it. draft true raises a purchase before anybody asks for a decision on it, so an access grant can be minted for its id. A draft is its raiser's alone: it is left out of every savings list and total, it is never the purchase already raised for its target, and it becomes an ordinary purchase the moment it is accepted or an execution request is filed for it. It expires at draft_expires_at, which is renewed when it is re-sized and moved to its access grant's end when that is later, and an expired draft is refused with 409 purchase_draft_expired wherever it is acted on. Raising a draft again for the same target and buying account returns the caller's own draft, re-sized to the new pick with rebound true, so it keeps its id and its grant. Another person's draft is never returned and never refuses a raise, so accepting a draft or filing its execution request asks again what a raise asks: 409 schedule_exists, same_purchase_exists or undecided_purchase_exists, the last two unless the draft was raised again with confirm_additional true. A draft takes a signed-in caller and a body naming kind (422 otherwise). A body without kind raises one payer's /purchase-plan proposal for a 12 month No Upfront plan, as before. profile picks a sized profile (balanced when neither is named), or commitment_hourly names a size of the caller's own, rounded down to $0.001 and refused above AWS's cap. Calling it again returns the purchase nobody has decided yet for that payer and plan type when it is bound to the same pick, and refuses any other pick with 409: PATCH /purchase/{recommendation_id} changes it. Also 409 when the proposal carries no size, when AWS's own recommendation was not fetched in the last 7 days, or when the size is under $0.001 an hour. payer_account_id may be omitted when the organization has one payer (422 otherwise; 404 for a payer that bills nothing). monthly_savings is the lower of the daily replay and AWS's estimate scaled to this size, both at the organization's own rates. attempt_id names one attempt at a purchase: sending the same attempt_id again, after a second click, a reload or a retry, returns the purchase that attempt raised with created false, and a different request under an attempt_id already used is refused with 422. A new attempt identical to a purchase awaiting approval, accepted, released, or sent to AWS in the last 24 hours is answered 409 same_purchase_exists with that purchase's recommendation_id, state, raised_by and raised_at; send it again with confirm_additional true to raise a second one. Any other difference, a quantity or a commitment for one, raises as usual. Either way, when the buying account is connected the row carries both implementation tags and an access policy that can only buy this one purchase, so Automated Savings can buy it; otherwise it is delivered by hand (Manual Setup) with no access policy. It bills only once AWS reports it active. - [Buy a released purchase now instead of after its hold](/api-reference/commitments/post-commitments-purchase-by-id-buy-now): A new purchase released to Automated Savings waits up to 24 hours before LevelFour sends it to AWS, so it can still be cancelled. This ends the wait: the executor sends it on its next run, within about five minutes. Organization admins only. Answers where the purchase stands; calling it again changes nothing. 409 purchase_not_held for a purchase that is not released to Automated Savings, tranche_keeps_its_day for a tranche of a purchase schedule, purchase_cancelled once it was cancelled, and purchase_already_sent once it was sent. 404 for an id that is no purchase. - [Cancel a purchase before it is sent to AWS](/api-reference/commitments/post-commitments-purchase-by-id-cancel): Stops a purchase that has not been sent to AWS: it is rejected, reads cancelled_before_purchase, and stays that way, so it cannot be accepted or released again; raise a new purchase to buy it. An organization admin or the member who raised or requested it may cancel. Calling it again changes nothing. 409 purchase_already_sent once a purchase was sent and may have been bought, purchase_closed for one already bought or rejected, and purchase_bought_by_hand for one released to be bought by hand, which may already be bought. 404 for an id that is no purchase. - [Renewal plan for one commitment](/api-reference/commitments/get-commitments-renewal-plan-by-id): What to repurchase and when. units_recommended is the quantity the renewal options recommend, the same number the drawer shows: 0 with decision let_expire when the commitment was used too little to pay for itself, the quantity held when nothing measured it. units_consumed is the average the sized series measured, null when unmeasured. States the instant to buy after rather than a date, because a replacement bills from the moment it is bought, and lists the pending changes to the same instance family that must land first so their capacity is not reserved for another term. superseded_by names the commitment that already renewed this one, and no_recent_usage says nothing of its family and engine ran last week; either one means no renewal is raised. - [Marketplace and contract commitments](/api-reference/commitments/get-commitments-contracts): Commitment relationships that behave like a commitment on the bill but appear in no provider commitment view, with the contracted floor separated from the metered leg that bills on top. --- # Per-service commitment breakdown Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-by-service.md ## GET /api/v1/commitments/by-service Returns a service x instrument matrix of RI and SP utilization, coverage, unused, and uncovered values. One row per service that has at least one commitment. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `provider` (string, required) - `services` (array of object, required) - `service` (string, required) - `service_label` (string, required) - `ri` (object, required) - `utilization_pct` (number, required) - `coverage_pct` (number, required) - `unused_monthly` (number, required) - `uncovered_monthly` (number, required) - `commitment_count` (integer, required) - `sp` (object, required) - `utilization_pct` (number, required) - `coverage_pct` (number, required) - `unused_monthly` (number, required) - `uncovered_monthly` (number, required) - `commitment_count` (integer, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Marketplace and contract commitments Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-contracts.md ## GET /api/v1/commitments/contracts Commitment relationships that behave like a commitment on the bill but appear in no provider commitment view, with the contracted floor separated from the metered leg that bills on top. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `vendor` (string, required) - `contract_monthly_usd` (number, nullable, required) - `latest_metered_usd` (number, required) - `overage_exceeds_floor` (boolean, required) - `months` (array of object, required) - `period` (string, required) - `contract_usd` (number, required) - `metered_usd` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get coverage filter options Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-coverage-filter-options.md ## GET /api/v1/commitments/coverage/filter-options ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `providers` (array of string, required) - `accounts` (array of string, required) - `services` (array of string, required) - `regions` (array of string, required) - `scopes` (array of string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # How much of the eligible bill a commitment covers Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-coverage-rates.md ## GET /api/v1/commitments/coverage-rates Coverage per service and the covered spend per consuming account, with the on-demand remainder beside it so the percentage names its own denominator. Weighted by the dollars behind each figure, never averaged across commitments, where a hundred dollar reservation would move the number as far as a fifty thousand dollar one. A provider whose sweep has not run reports measured false rather than zero coverage, because holding none and not having looked are opposite answers. months adds that many calendar months ending at the current month, AWS only, for the organization, each payer and each account where the workloads run: instance hours at public on-demand prices covered and uncovered, and the commitment dollars each account consumed, took from other accounts, gave to them and left idle. On Google Cloud, projects lists each project's committed use discounts month to date, and cost_export_cud_status says whether the billing export has been swept. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `months` (query, integer, default `0`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `provider` (string, required) - `measured` (boolean, required) - `services` (array of object) - `instrument` (string, required) - `dimension` (string, required) - `dimension_label` (string, required) - `coverage_pct` (number, required) - `measured_on` (string, nullable, required) - `accounts` (array of object) - `account_id` (string, required) - `account_name` (string, required) - `covered_monthly` (number, required) - `uncovered_by_service` (array of object) - `service` (string, required) - `on_demand_monthly` (number, required) - `totals` (object, nullable, required) - `covered_monthly` (number, nullable) - `uncovered_monthly` (number, required) - `coverage_pct` (number, nullable, required) - `utilization_pct` (number, nullable) - `unused_monthly` (number, nullable) - `committed_monthly` (number, nullable) - `period` (string, nullable) - `complete` (boolean, nullable) - `series` (array of object) - `period` (string, required) - `complete` (boolean, required) - `eligible_on_demand` (number, required) - `covered_on_demand` (number, required) - `uncovered_on_demand` (number, required) - `coverage_pct` (number, nullable, required) - `covered_cost` (number, required) - `utilization_pct` (number, nullable, required) - `shared_cost` (number, required) - `unused_cost` (number, nullable, required) - `account_series` (array of object) - `account_id` (string, required) - `account_name` (string, required) - `organization_id` (string, nullable, required) - `months` (array of object, required) - `period` (string, required) - `complete` (boolean, required) - `eligible_on_demand` (number, required) - `covered_on_demand` (number, required) - `uncovered_on_demand` (number, required) - `coverage_pct` (number, nullable, required) - `covered_cost` (number, required) - `utilization_pct` (number, nullable, required) - `shared_in_cost` (number, required) - `shared_out_cost` (number, required) - `held_unused_cost` (number, nullable, required) - `benefiting_commitments` (integer, required) - `organizations` (array of object) - `organization_id` (string, nullable, required) - `cross_account_share_pct` (number, nullable, required) - `series` (array of object, required) - `period` (string, required) - `complete` (boolean, required) - `eligible_on_demand` (number, required) - `covered_on_demand` (number, required) - `uncovered_on_demand` (number, required) - `coverage_pct` (number, nullable, required) - `covered_cost` (number, required) - `utilization_pct` (number, nullable, required) - `shared_cost` (number, required) - `unused_cost` (number, nullable, required) - `projects` (array of object) - `project_id` (string, required) - `project_name` (string, required) - `billing_account_id` (string, nullable, required) - `scope` (one of `project`, `billing_account`, required) - `service` (string, required) - `region` (string, required) - `kind` (one of `reserved_instance`, `savings_plan`, `committed_use_discount`, `spend_commitment`, required) - `commitment_fee_month_to_date` (number, required) - `coverage_pct` (number, nullable, required) - `utilization_pct` (number, nullable, required) - `as_of` (string, required) - `cost_export_cud_status` (one of `not_enabled`, `pending`, `error`, `active`, nullable) - `cud_export_status` (one of `not_enabled`, `provisioning`, `active`, `action_needed`, `error`, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get coverage rows Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-coverage.md ## GET /api/v1/commitments/coverage Deprecated. Deprecated, use /coverage-rates. This reads one row per commitment held, so usage no commitment covers has no row and cannot appear, which is the larger half of coverage. on_demand_cost_monthly, saved_vs_od_monthly and filtered_savings_monthly are not measured here and are sent as zero, and filtered_coverage_pct averages percentages across commitments rather than weighting them by spend. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, nullable) - `page` (query, integer, default `1`) - `page_size` (query, integer, default `20`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `row_id` (string, required) - `provider` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_id` (string, required) - `account_name` (string, required) - `usage_cost_monthly` (number, required) - `on_demand_cost_monthly` (number, required) - `coverage_pct` (number, nullable, required) - `committed_cost_monthly` (number, required) - `saved_vs_od_monthly` (number, required) - `utilization_pct` (number, nullable, required) - `is_commitable` (boolean, required) - `metadata` (object, required) - `filtered_usage_monthly` (number, required) - `filtered_coverage_pct` (number, required) - `filtered_savings_monthly` (number, required) - `pagination` (object, required) - `page` (integer, required) - `page_size` (integer, required) - `total` (integer, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get commitment detail Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-detail-by-id.md ## GET /api/v1/commitments/detail/{commitment_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `provider` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_id` (string, required) - `account_name` (string, required) - `region` (string, required) - `kind` (one of `reserved_instance`, `savings_plan`, `committed_use_discount`, `spend_commitment`, required) - `start_date` (string, required) - `end_date` (string, required) - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, nullable, required) - `hourly_commitment_usd` (number, nullable, required) - `monthly_commitment_usd` (number, required) - `status` (one of `active`, `expiring`, `expired`, `cancelled`, required) - `current_utilization_pct` (number, required) - `current_coverage_pct` (number, nullable, required) - `is_commitable` (boolean, required) - `figures_source` (one of `cur`, `cost_explorer`, default `cost_explorer`) - `end_at` (string, nullable) - `holder_account_id` (string, nullable) - `unit_count` (integer, default `1`) - `fee_monthly_list` (number, nullable) - `fee_monthly_net` (number, nullable) - `utilization_slope_pp_30d` (number, nullable) - `protects_monthly` (number, nullable) - `rightsizing_monthly` (number, nullable) - `exchangeable` (boolean, nullable) - `cancellable` (boolean, nullable) - `renewal_recommendation_id` (string, nullable) - `utilization_history` (array of object) - `date` (string, required) - `utilization_pct` (number, required) - `purchased_hours` (number, required) - `used_hours` (number, required) - `unused_cost` (number, required) - `coverage_history` (array of object) - `date` (string, required) - `coverage_pct` (number, nullable, required) - `consumers` (array of object) - `account_id` (string, required) - `account_name` (string, required) - `covered_spend_monthly` (number, required) - `covered_hours` (number, required) - `history` (array of object) - `id` (string, required) - `type` (one of `purchased`, `renewed`, `expired`, required) - `timestamp` (string, required) - `actor` (object, required) - `email` (string, nullable) - `account_id` (string, required) - `details` (map of string to string, required) - `recommendations` (array of object) - `id` (string, required) - `kind` (one of `buy_reserved_instance`, `buy_savings_plan`, `cancel_underutilized`, required) - `monthly_savings` (number, required) - `confidence` (one of `high`, `medium`, `low`, required) - `summary` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Effective savings rate, globally and per account Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-esr.md ## GET /api/v1/commitments/esr What the same usage would have cost at public on-demand rates against what it actually cost, for the organization and for each account that consumed the benefit. The rate is split into the part a negotiated agreement earns and the part commitments earn, because the public on-demand price sits above both and an account owning no commitment at all would otherwise report a healthy rate. esr_pct is the rate after idle commitment, and is null until the month is complete. months adds that many calendar months ending at period, for the organization, each payer and each account. ### Authentication Send `Authorization: Bearer `. ### Parameters - `scope` (query, string, default `eligible`) - `period` (query, string, nullable) - `months` (query, integer, default `0`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `period` (string, nullable, required) - `scope` (string, required) - `measured_share_pct` (number, required) - `totals` (object, nullable, required) - `measured` (boolean, required) - `on_demand_equivalent` (number, required) - `effective_cost` (number, required) - `commitment_saved` (number, required) - `negotiated_saved` (number, required) - `total_saved` (number, required) - `commitment_rate_pct` (number, nullable, required) - `negotiated_rate_pct` (number, nullable, required) - `total_rate_pct` (number, nullable, required) - `unused_commitment_cost` (number, nullable) - `unused_rate_pct` (number, nullable) - `esr_pct` (number, nullable) - `accounts` (array of object, required) - `measured` (boolean, required) - `on_demand_equivalent` (number, required) - `effective_cost` (number, required) - `commitment_saved` (number, required) - `negotiated_saved` (number, required) - `total_saved` (number, required) - `commitment_rate_pct` (number, nullable, required) - `negotiated_rate_pct` (number, nullable, required) - `total_rate_pct` (number, nullable, required) - `unused_commitment_cost` (number, nullable) - `unused_rate_pct` (number, nullable) - `esr_pct` (number, nullable) - `account_id` (string, required) - `account_name` (string, required) - `latest_complete_period` (string, nullable) - `excluded_non_usd_cost` (number, default `0`) - `series` (array of object) - `measured` (boolean, required) - `on_demand_equivalent` (number, required) - `effective_cost` (number, required) - `commitment_saved` (number, required) - `negotiated_saved` (number, required) - `total_saved` (number, required) - `commitment_rate_pct` (number, nullable, required) - `negotiated_rate_pct` (number, nullable, required) - `total_rate_pct` (number, nullable, required) - `unused_commitment_cost` (number, nullable) - `unused_rate_pct` (number, nullable) - `esr_pct` (number, nullable) - `period` (string, required) - `complete` (boolean, required) - `measured_share_pct` (number, required) - `account_series` (array of object) - `account_id` (string, required) - `account_name` (string, required) - `organization_id` (string, nullable, required) - `months` (array of object, required) - `measured` (boolean, required) - `on_demand_equivalent` (number, required) - `effective_cost` (number, required) - `commitment_saved` (number, required) - `negotiated_saved` (number, required) - `total_saved` (number, required) - `commitment_rate_pct` (number, nullable, required) - `negotiated_rate_pct` (number, nullable, required) - `total_rate_pct` (number, nullable, required) - `unused_commitment_cost` (number, nullable) - `unused_rate_pct` (number, nullable) - `esr_pct` (number, nullable) - `period` (string, required) - `complete` (boolean, required) - `organizations` (array of object) - `organization_id` (string, nullable, required) - `series` (array of object, required) - `measured` (boolean, required) - `on_demand_equivalent` (number, required) - `effective_cost` (number, required) - `commitment_saved` (number, required) - `negotiated_saved` (number, required) - `total_saved` (number, required) - `commitment_rate_pct` (number, nullable, required) - `negotiated_rate_pct` (number, nullable, required) - `total_rate_pct` (number, nullable, required) - `unused_commitment_cost` (number, nullable) - `unused_rate_pct` (number, nullable) - `esr_pct` (number, nullable) - `period` (string, required) - `complete` (boolean, required) - `measured_share_pct` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Where the purchase renewing this commitment stands Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-execution-by-id.md ## GET /api/v1/commitments/execution/{commitment_id} The latest purchase run of this commitment's renewal, with the steps, logs and trigger of the recommendation that renews it. not_started until somebody releases the renewal. queued once released and waiting for the term to end, or once the provider queued the purchase. in_progress while a run holds it. active once the provider reports the new commitment active, or once the renewal was completed by hand. payment_failed when the provider could not charge for it. failed when a run was refused before sending or the provider refused the purchase. indeterminate when nobody knows yet whether the purchase went through: it is reconciled, never retried. A commitment the organization does not hold is a 404. ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `commitment_id` (string, required) - `status` (one of `not_started`, `in_progress`, `active`, `queued`, `payment_failed`, `failed`, `indeterminate`, required) - `trigger_info` (object, nullable) - `execution_steps` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `duration_seconds` (integer, nullable) - `parent_step_id` (string, nullable) - `execution_logs` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `commands` (array of object) - `order` (integer, required) - `command` (string, required) - `output` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # One row per commitment Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-inventory.md ## GET /api/v1/commitments/inventory Every reservation or plan the tenant holds, one row each, with the state the cloud reports rather than one derived from the end date. A plan that starts next month is queued and one whose payment failed is neither running nor expired, and no date says either. Attributes only some services carry arrive as null rather than zero. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `instrument` (query, string, required) - `service` (query, string, nullable) - `state` (query, string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `provider` (string, required) - `instrument` (one of `ri`, `sp`, required) - `rows` (array of object, required) - Variant 1: InventoryReservationRow - `id` (string, required) - `account_id` (string, required) - `account_name` (string, required) - `state` (one of `active`, `queued`, `payment-pending`, `payment-failed`, `retired`, required) - `payment_option` (string, required) - `start` (string, required) - `end` (string, required) - `end_at` (string, nullable) - `end_is_derived` (boolean, required) - `term_months` (integer, required) - `currency` (string, nullable, required) - `arn` (string, nullable, required) - `tags` (map of string to string, nullable, required) - `tag` (string, nullable) - `product_description` (string, nullable, required) - `committed_monthly` (number, required) - `saved_monthly` (number, nullable, required) - `unused_monthly` (number, nullable, required) - `on_demand` (object, nullable) - `kind` (`reserved_instance`, required) - `service` (string, required) - `service_label` (string, required) - `resource` (string, required) - `qty` (integer, required) - `region` (string, required) - `fixed_price` (number, nullable, required) - `recurring_hourly` (number, nullable, required) - `usage_hourly` (number, nullable, required) - `offering_class` (string, nullable, required) - `scope` (string, nullable, required) - `availability_zone` (string, nullable, required) - `tenancy` (string, nullable, required) - `multi_az` (boolean, nullable, required) - `node_offering` (string, nullable, required) - `reservation_name` (string, nullable, required) - Variant 2: InventoryPlanRow - `id` (string, required) - `account_id` (string, required) - `account_name` (string, required) - `state` (one of `active`, `queued`, `payment-pending`, `payment-failed`, `retired`, required) - `payment_option` (string, required) - `start` (string, required) - `end` (string, required) - `end_at` (string, nullable) - `end_is_derived` (boolean, required) - `term_months` (integer, required) - `currency` (string, nullable, required) - `arn` (string, nullable, required) - `tags` (map of string to string, nullable, required) - `tag` (string, nullable) - `product_description` (string, nullable, required) - `committed_monthly` (number, required) - `saved_monthly` (number, nullable, required) - `unused_monthly` (number, nullable, required) - `on_demand` (object, nullable) - `kind` (`savings_plan`, required) - `plan_type` (string, required) - `commitment_hourly` (number, required) - `region` (string, nullable, required) - `instance_family` (string, nullable, required) - `product_types` (array of string, required) - `upfront` (number, nullable, required) - `recurring_hourly` (number, nullable, required) - `recurring_monthly` (number, nullable, required) - `returnable_until` (string, nullable, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # What AWS sells for a reservation or Savings Plan target Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-offerings.md ## GET /api/v1/commitments/offerings The catalogue one field at a time. choices lists, for every field of the target, the values sold given every field before it, in the order each narrows the next: service, region, engine, deployment, offering_class, tenancy and resource for a reservation, plan_type, instance_family and region for a plan. A field the service does not use has no choices and must be left out. complete says the target is whole; offers then lists every offering sold for it, priced per unit (a reservation, or a dollar an hour of a plan) beside the public on-demand rate where the price list has one. EC2 is read one instance type at a time, so its offers wait for resource. Regions are where the organization's accounts live, hold commitments, run anything or opted in; classes are those it runs and those the catalogue sells. Every page AWS returns is kept a day, fetched_at says when it was read. AWS is read through the role of the optional buying_account_id, the account the purchase would be bought in, then its payer's, then every other connected account's, payers first, passing over a role AWS refuses to assume. 422 for a value the service does not sell; 502 when every connected account refused the offerings lookup. ### Authentication Send `Authorization: Bearer `. ### Parameters - `kind` (query, one of `reserved_instance`, `savings_plan`, required) - `service` (query, one of `ec2`, `rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`, nullable) - `region` (query, string, nullable) - `engine` (query, string, nullable) - `deployment` (query, one of `single_az`, `multi_az`, nullable) - `offering_class` (query, one of `standard`, `convertible`, nullable) - `tenancy` (query, one of `default`, `dedicated`, nullable) - `plan_type` (query, one of `compute`, `database`, `ec2_instance`, nullable) - `instance_family` (query, string, nullable) - `resource` (query, string, nullable) - `buying_account_id` (query, string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required): What the catalog sells for a target: the choices for every field, and the offers once it is whole. - `choices` (map of string to array of string, required) - `complete` (boolean, required) - `offers` (array of object, required) - `offering_id` (string, required) - `resource` (string, nullable, required) - `term_months` (integer, required) - `payment_option` (string, required) - `currency` (string, required) - `upfront` (number, required) - `recurring_hourly` (number, required) - `term_total` (number, required) - `effective_hourly` (number, required) - `on_demand_hourly` (number, nullable, required) - `offering_class` (one of `standard`, `convertible`, nullable, required) - `multi_az` (boolean, nullable, required) - `fetched_at` (string, nullable, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get commitments overview Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-overview.md ## GET /api/v1/commitments/overview ESR strip and aggregate RI/SP metrics for the requested provider (AWS real, GCP/Azure return zeros). ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `commitment_type` (query, string, default `all`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `global_esr` (number, required) - `benchmark_esr` (number, required) - `total_committed_monthly` (number, required) - `total_cloud_spend_monthly` (number, required) - `coverage_pct` (number, required) - `coverage_delta_pp` (number, required) - `estimated_waste_monthly` (number, required) - `waste_commitment_count` (integer, required) - `potential_additional_savings_monthly` (number, required) - `commitment_count` (integer, required) - `expiring_count` (integer, required) - `accounts` (array of object, required) - `account_id` (string, required) - `account_name` (string, required) - `monthly_spend` (number, required) - `esr_pct` (number, required) - `monthly_saved` (number, required) - `could_save_additional` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Commitment portfolio ledger Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-portfolio.md ## GET /api/v1/commitments/portfolio One row per commitment across every connected account, resolving the account that holds it against the accounts that consume it, with the expiry instant, the utilization trend, and what a renewal protects kept separate from what right-sizing would save. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `basis` (query, string, default `net`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `basis` (one of `list`, `net`, required) - `totals` (object, required) - `commitment_count` (integer, required) - `hourly_commitment_usd` (number, required) - `fee_monthly_list` (number, required) - `fee_monthly_net` (number, required) - `protects_monthly` (number, required) - `rightsizing_monthly` (number, required) - `expiring_within_30d` (integer, required) - `organization_count` (integer, required) - `rows` (array of object, required) - `id` (string, required) - `service` (string, required) - `service_label` (string, required) - `kind` (one of `reserved_instance`, `savings_plan`, `committed_use_discount`, `spend_commitment`, required) - `holder_account_id` (string, required) - `holder_account_name` (string, required) - `consumers` (array of object, required) - `account_id` (string, required) - `account_name` (string, required) - `covered_spend_monthly` (number, required) - `covered_hours` (number, required) - `organization_id` (string, required) - `region` (string, required) - `applies_to` (string, nullable, required) - `unit_count` (integer, required) - `unit_rate_hourly` (number, nullable, required) - `hourly_commitment_usd` (number, nullable, required) - `fee_monthly_list` (number, nullable, required) - `fee_monthly_net` (number, nullable, required) - `end_at` (string, nullable, required) - `expires_in_seconds` (integer, nullable, required) - `utilization_pct` (number, required) - `coverage_pct` (number, required) - `utilization_slope_pp_30d` (number, nullable, required) - `saturated` (boolean, required) - `protects_monthly` (number, nullable, required) - `rightsizing_monthly` (number, nullable, required) - `exchangeable` (boolean, nullable, required) - `cancellable` (boolean, nullable, required) - `predecessor_id` (string, nullable, required) - `status` (one of `active`, `expiring`, `expired`, `cancelled`, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Where a Savings Plan purchase stands Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-purchase-by-id.md ## GET /api/v1/commitments/purchase/{recommendation_id} One BUY- purchase: what it buys, the tranche it belongs to, and its latest purchase run with the steps, logs and trigger of its row. execution.status reads as it does for a renewal, plus returned when AWS refunded the plan after it was bought, and cancelled when a plan queued inside AWS was deleted while queued. execution.starts_at is when AWS starts such a plan: only runs sent before LevelFour stopped queueing inside AWS carry either. bought_outside names the plan of the same type and term bought outside LevelFour that stopped the latest run. execution.status reads scheduled while a released purchase waits on LevelFour's side until execution.send_at, and cancelled_before_purchase once it was cancelled before it was sent. execution.can_buy_now and execution.can_cancel say whether the caller may buy it now or cancel it at this moment, by the rules those two routes apply. Reads stored state only and never calls AWS. 404 for an id that is no purchase of this organization. ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database` or `ec2_instance`, nullable, required): Null for a reservation - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `commitment_hourly` (number, nullable, required): Null for a reservation - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `capped_by` (array of string, required) - `monthly_savings` (number, required) - `grain` (`daily`, nullable, required): Null when no usage was replayed to price the purchase - `source` (`cur`, nullable, required) - `created_by` (string, nullable) - `created_at` (string, required) - `rebindable` (boolean, default `false`) - `kind` (one of `reserved_instance`, `savings_plan`, nullable): Null on a plan raised from its proposal, before either instrument had terms - `origin` (one of `recommended`, `custom`, nullable) - `target` (object, nullable): The ReservationTarget or PlanTarget the purchase was raised for - `resource` (string, nullable): The class a reservation buys - `quantity` (integer, nullable): How many reservations it buys - `offering_id` (string, nullable) - `quote` (object, nullable): The PurchaseQuoteData it was raised on, without its series - `draft` (boolean, default `false`): Raised before anybody asked for a decision on it. A draft is its raiser's alone: out of every list and total until it is accepted or requested - `draft_expires_at` (string, nullable): When a draft dies unless it is accepted or requested first. Null for a purchase that is no draft - `buying_account_id` (string, required) - `schedule_id` (string, nullable) - `tranche_number` (integer, nullable) - `tranches_total` (integer, nullable) - `buy_after_utc` (string, nullable) - `verified_active_at` (string, nullable): When the executor saw AWS report the plan active - `execution` (object, required) - `status` (one of `not_started`, `in_progress`, `active`, `queued`, `payment_failed`, `failed`, `indeterminate`, `scheduled`, `cancelled_before_purchase`, `returned`, `cancelled`, required) - `provider_state` (string, nullable): AWS's own state for the plan, once it was sent - `purchase_id` (string, nullable): The Savings Plan id AWS answered with - `error` (string, nullable) - `starts_at` (string, nullable): When AWS starts a plan it was asked to queue. Only purchases sent before LevelFour stopped queueing inside AWS carry one. - `send_at` (string, nullable): When LevelFour sends a released purchase to AWS: once its hold ends, or on its scheduled day for a tranche. Until then it reads scheduled, and it can be bought now or cancelled. - `can_buy_now` (boolean, default `false`): The caller may end its hold now: an organization admin, on a new purchase released to Automated Savings that is still held and was not sent. - `can_cancel` (boolean, default `false`): The caller may cancel it now: an organization admin or the member who raised or requested it, while it was not sent to AWS. - `bought_outside` (object, nullable): The plan of the same type and term bought outside LevelFour that stopped this one - `plan_id` (string, required) - `started_on` (string, nullable) - `trigger_info` (object, nullable) - `execution_steps` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `duration_seconds` (integer, nullable) - `parent_step_id` (string, nullable) - `execution_logs` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `commands` (array of object) - `order` (integer, required) - `command` (string, required) - `output` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Savings Plans and Reserved Instances to buy, sized on the daily bill Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-purchase-plan.md ## GET /api/v1/commitments/purchase-plan The uncovered base, as /uncovered returns it, beside one proposal per payer and plan type at 12 months No Upfront. Each proposal offers three profiles sized on the daily CUR floor, net of the commitment already bought or agreed, capped by AWS's own recommendation, and replayed over the last 60 complete days. Free to every organization; reads cached rates and offerings and never calls AWS. raised is null until a proposal can be raised as a purchase. schedule lists every tranche of each purchase schedule with a month still to come: its day, its size, where it stands and why it last shrank or was skipped. reservations lists the new Reserved Instances LevelFour would buy for on-demand usage no Savings Plan covers, each sized on the 10th percentile day, quoted on the cached offering at the guardrails' term, and flagged quota_blocked when the RDS reserved instance quota cannot hold it. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `provider` (`aws`, required) - `uncovered` (array of object, required) - `service` (string, required) - `organization_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database`, required) - `platform` (string, required) - `on_demand_hourly_avg` (number, required) - `on_demand_hourly_min` (number, required) - `volatility_ratio` (number, required) - `recommended_kind` (string, nullable, required) - `suggested_commitment_hourly` (number, required) - `grain` (one of `daily`, `hourly`, required) - `source` (one of `cur`, `cost_explorer_recommendation`, required) - `window_days` (integer, required) - `fetched_on` (date string, nullable) - `proposals` (array of object, required) - `grain` (`daily`, default `daily`) - `source` (`cur`, default `cur`) - `basis` (`public_on_demand`, default `public_on_demand`) - `currency` (`USD`, default `USD`) - `payer_account_id` (string, nullable, required) - `plan_type` (one of `compute`, `database`, required) - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `unavailable_reason` (one of `schema_pending`, `no_cur`, `insufficient_history`, `rates_pending`, `payer_not_connected`, `no_on_demand_equivalent`, nullable, required) - `window` (object, nullable, required) - `first_day` (date string, required) - `last_day` (date string, required) - `days` (integer, required) - `missing_days` (integer, required) - `last_loaded` (date string, required) - `caveats` (array of string, required) - `profiles` (object, nullable) - `conservative` (object, required) - `balanced` (object, required) - `max_savings` (object, required) - `profiles_inverted` (boolean, default `false`) - `guards` (array of object) - `name` (string, required) - `limit` (number, required) - `applied` (boolean, required) - `basis` (string, required) - `aws` (object, nullable) - `hourly_commitment_to_purchase` (number, required) - `cap` (number, required) - `cap_basis` (one of `direct`, `converted_from_12_no_upfront`, required) - `cap_applied` (boolean, required) - `lookback_days` (integer, required) - `fetched_on` (date string, required) - `generated_at` (date-time string, required) - `min_hourly_on_demand` (number, nullable, required) - `avg_hourly_on_demand` (number, nullable, required) - `estimated_utilization_pct` (number, nullable, required) - `estimated_monthly_savings` (number, nullable, required) - `basis_check_pct` (number, nullable, required) - `basis_check_flagged` (boolean, required) - `gap` (array of object, required) - `ledger` (array of object) - `source` (one of `queued_plan`, `recent_plan`, `accepted_purchase`, `recent_reservation`, `renewal_growth`, required) - `reference` (string, required) - `commitment_hourly` (number, nullable, required) - `on_demand_hourly` (number, nullable, required) - `scope` (object, nullable, required) - `effective_from` (date string, nullable, required) - `ledger_hourly` (number, default `0`) - `stale_claims` (array of object) - `reference` (string, required) - `commitment_hourly` (number, nullable, required) - `on_demand_hourly` (number, nullable) - `accepted_days_ago` (integer, required) - `unmapped_holders` (array of string) - `window_changes` (array of object) - `reference` (string, required) - `kind` (string, required) - `change` (one of `started`, `ended`, required) - `day` (date string, required) - `excluded` (array of object) - `reason` (string, required) - `on_demand_daily` (number, required) - `recommended_profile` (`balanced`, nullable, required) - `raised` (object, nullable) - `recommendation_id` (string, required) - `commitment_hourly` (number, required) - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `created_at` (date-time string, required) - `quantity` (integer, nullable): How many reservations a reservation purchase buys - `schedule` (array of object) - `id` (string, required) - `schedule_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database`, required) - `profile` (one of `conservative`, `balanced`, `max_savings`, required) - `tranche_number` (integer, required) - `tranches_total` (integer, required) - `tranche_month` (date string, required) - `scheduled_for` (date string, required) - `commitment_hourly` (number, required) - `status` (one of `planned`, `awaiting_decision`, `accepted`, `released`, `buying`, `active`, `failed`, `rejected`, `skipped`, `cancelled`, required) - `reason` (string, required) - `reason_detail` (object, required) - `recommendation_id` (string, nullable, required) - `resized_at` (date-time string, nullable, required) - `reservations` (array of object) - `id` (string, required) - `payer_account_id` (string, required) - `buying_account_id` (string, required) - `target` (object, required): What a reservation is bought for: the service and where its usage runs. - `kind` (`reserved_instance`, required) - `service` (one of `ec2`, `rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`, required) - `region` (string, required) - `engine` (string, nullable) - `deployment` (one of `single_az`, `multi_az`, nullable) - `offering_class` (one of `standard`, `convertible`, nullable) - `tenancy` (one of `default`, `dedicated`, nullable) - `resource` (string, nullable, required) - `quantity` (integer, nullable, required) - `term_months` (integer, required) - `payment_option` (string, required) - `offering_id` (string, nullable, required) - `quote` (object, nullable, required) - `basis` (one of `usage`, `full_use`, required) - `offering_id` (string, nullable, required): The offering priced. Null for a Savings Plan quoted without one: a plan costs its commitment on its term and payment option, so it is priced on those without reading the catalogue - `currency` (string, required) - `term_months` (integer, required) - `payment_option` (string, required) - `upfront` (number, required) - `recurring_monthly` (number, required) - `commitment_monthly` (number, required) - `term_total` (number, required) - `covered_on_demand_monthly` (number, required) - `left_on_demand_monthly` (number, required) - `unused_monthly` (number, required) - `unused_units` (number, nullable): Reservations of the class bought left unused, an hour on average over the window. Null at full use and for a Savings Plan - `left_on_demand_units` (number, nullable): Usage left on demand, an hour on average over the window, in reservations of the class bought. Null at full use and for a Savings Plan - `net_savings_monthly` (number, required) - `savings_pct` (number, required) - `coverage_before_pct` (number, nullable, required) - `coverage_after_pct` (number, nullable, required) - `utilization_pct` (number, nullable, required) - `break_even_months` (integer, nullable, required) - `capacity` (number, required) - `over_automation_cap` (boolean, required) - `pool` (object, nullable, required) - `series` (array of object, required) - `window` (object, nullable, required) - `first_day` (date string, required) - `last_day` (date string, required) - `days` (integer, required) - `missing_days` (integer, required) - `last_loaded` (date string, required) - `note` (one of `licensed_engine`, `size_flexible`, nullable, required) - `unavailable_reason` (one of `no_cur`, `insufficient_history`, `catalog_pending`, `no_offering`, nullable, required) - `raised` (object, nullable) - `recommendation_id` (string, required) - `commitment_hourly` (number, required) - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `created_at` (date-time string, required) - `quantity` (integer, nullable): How many reservations a reservation purchase buys - `quota_blocked` (boolean, default `false`) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # What one Savings Plan commitment would have done Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-purchase-simulation.md ## GET /api/v1/commitments/purchase-simulation Replays a commitment in dollars an hour over the payer's recent daily bill: the eligible on-demand spend it would have covered, the commitment it would have left unused, its utilization, coverage and monthly savings, day by day, beside the sized profiles and AWS's own figure. A day averages its hours, so every figure is an upper bound on the hourly truth. Free to every organization; never calls AWS. A Database plan is sold for 12 months No Upfront only. payer_account_id may be omitted when the organization has one payer. Missing data answers 200 with unavailable_reason. ### Authentication Send `Authorization: Bearer `. ### Parameters - `plan_type` (query, one of `compute`, `database`, required) - `commitment_hourly` (query, number, required) - `term_months` (query, one of `12`, `36`, default `12`) - `payment_option` (query, string, default `no_upfront`) - `payer_account_id` (query, string, nullable) - `lookback_days` (query, one of `30`, `60`, default `60`) - `curve` (query, boolean, default `false`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `grain` (`daily`, default `daily`) - `source` (`cur`, default `cur`) - `basis` (`public_on_demand`, default `public_on_demand`) - `currency` (`USD`, default `USD`) - `payer_account_id` (string, nullable, required) - `plan_type` (one of `compute`, `database`, required) - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `unavailable_reason` (one of `schema_pending`, `no_cur`, `insufficient_history`, `rates_pending`, `payer_not_connected`, `no_on_demand_equivalent`, nullable, required) - `window` (object, nullable, required) - `first_day` (date string, required) - `last_day` (date string, required) - `days` (integer, required) - `missing_days` (integer, required) - `last_loaded` (date string, required) - `caveats` (array of string, required) - `profiles` (object, nullable) - `conservative` (object, required) - `commitment_hourly` (number, required) - `uncapped_hourly` (number, required) - `percentile` (number, required) - `capped_by` (array of string, required) - `utilization_pct` (number, nullable, required) - `coverage_pct` (number, nullable, required) - `net_savings_monthly` (number, required) - `net_savings_monthly_at_your_rates` (number, required) - `balanced` (object, required) - `commitment_hourly` (number, required) - `uncapped_hourly` (number, required) - `percentile` (number, required) - `capped_by` (array of string, required) - `utilization_pct` (number, nullable, required) - `coverage_pct` (number, nullable, required) - `net_savings_monthly` (number, required) - `net_savings_monthly_at_your_rates` (number, required) - `max_savings` (object, required) - `commitment_hourly` (number, required) - `uncapped_hourly` (number, required) - `percentile` (number, required) - `capped_by` (array of string, required) - `utilization_pct` (number, nullable, required) - `coverage_pct` (number, nullable, required) - `net_savings_monthly` (number, required) - `net_savings_monthly_at_your_rates` (number, required) - `profiles_inverted` (boolean, default `false`) - `guards` (array of object) - `name` (string, required) - `limit` (number, required) - `applied` (boolean, required) - `basis` (string, required) - `aws` (object, nullable) - `hourly_commitment_to_purchase` (number, required) - `cap` (number, required) - `cap_basis` (one of `direct`, `converted_from_12_no_upfront`, required) - `cap_applied` (boolean, required) - `lookback_days` (integer, required) - `fetched_on` (date string, required) - `generated_at` (date-time string, required) - `min_hourly_on_demand` (number, nullable, required) - `avg_hourly_on_demand` (number, nullable, required) - `estimated_utilization_pct` (number, nullable, required) - `estimated_monthly_savings` (number, nullable, required) - `basis_check_pct` (number, nullable, required) - `basis_check_flagged` (boolean, required) - `gap` (array of object, required) - `code` (one of `ledger_claims`, `eligibility_scope`, `daily_grain`, `lookback_edge`, required) - `commitment_hourly` (number, nullable, required) - `ledger` (array of object) - `source` (one of `queued_plan`, `recent_plan`, `accepted_purchase`, `recent_reservation`, `renewal_growth`, required) - `reference` (string, required) - `commitment_hourly` (number, nullable, required) - `on_demand_hourly` (number, nullable, required) - `scope` (object, nullable, required) - `family` (string, required) - `region` (string, required) - `effective_from` (date string, nullable, required) - `ledger_hourly` (number, default `0`) - `stale_claims` (array of object) - `reference` (string, required) - `commitment_hourly` (number, nullable, required) - `on_demand_hourly` (number, nullable) - `accepted_days_ago` (integer, required) - `unmapped_holders` (array of string) - `window_changes` (array of object) - `reference` (string, required) - `kind` (string, required) - `change` (one of `started`, `ended`, required) - `day` (date string, required) - `excluded` (array of object) - `reason` (string, required) - `on_demand_daily` (number, required) - `candidate` (object, required) - `commitment_hourly` (number, required) - `totals` (object, nullable) - `eligible_on_demand` (number, required) - `covered_on_demand` (number, required) - `uncovered_on_demand` (number, required) - `commitment_cost` (number, required) - `used_commitment` (number, required) - `wasted_commitment` (number, required) - `utilization_pct` (number, nullable, required) - `coverage_pct` (number, nullable, required) - `net_savings_monthly` (number, required) - `net_savings_monthly_at_your_rates` (number, required) - `series` (array of object) - `day` (date string, required) - `eligible_on_demand` (number, required) - `covered_on_demand` (number, required) - `uncovered_on_demand` (number, required) - `used_commitment` (number, required) - `wasted_commitment` (number, required) - `commitment_equivalent_hourly` (number, required) - `curve` (array of object) - `commitment_hourly` (number, required) - `net_savings_monthly` (number, required) - `utilization_pct` (number, nullable, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get commitment recommendations Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-recommendations.md ## GET /api/v1/commitments/recommendations ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, nullable) - `commitment_type` (query, string, default `all`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `kind` (one of `buy_savings_plan`, `buy_reserved_instance`, `cancel_underutilized`, `convert_ri_to_sp`, `rightsize_commitment`, required) - `title` (string, required) - `detail` (string, required) - `monthly_savings` (number, nullable, required) - `confidence` (one of `high`, `medium`, `low`, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # The renewal purchase already raised for this commitment Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-renewal-by-id.md ## GET /api/v1/commitments/renewal/{commitment_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `commitment_id` (string, required) - `recommendation_id` (string, required) - `created_by` (string, nullable) - `created_at` (string, nullable) - `created` (boolean, default `false`) - `rebound` (boolean, default `false`) - `rebindable` (boolean, default `false`) - `closed` (boolean, default `false`) - `offering_id` (string, nullable) - `quantity` (number, nullable) - `blocking_changes` (array of object) - `recommendation_id` (string, required) - `service` (string, required) - `account` (string, required) - `monthly_savings` (number, required) - `status` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Every term this commitment could be replaced with Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-renewal-options-by-id.md ## GET /api/v1/commitments/renewal-options/{commitment_id} The contract priced again as it stands, beside every other term its shape is sold on today, each priced at up to about forty quantities spanning what was sized and what is held, and recommended on exactly one row, above the quantity held only on a family basis, or on none when decision is let_expire: the commitment was used too little to pay for itself, so every row is priced at the quantity held for comparison and nothing is advised. With quantity, each offering is priced at exactly that quantity instead, one row each; a reservation is sold in whole units, so a fractional quantity is a 422. Without a stored series every quantity is still priced, and idle_units, spill_units and spill_cost come back null. sizing_basis says where the recommended quantity came from: the commitment's own fee line, which can only argue for buying less; family, that line plus the on-demand usage of its family and engine held on nine days in ten, the one basis that can argue for more; or unmeasured, where the quantity held stands; measured appears only on options an earlier sweep priced. upfront is the total paid at purchase for the row's quantity; recurring_hourly is the rate per unit. demand is the daily series the sizing read, with its projection across the longest term. A size-flexible RDS, ElastiCache or MemoryDB reservation can be renewed as a larger class of its family: classes lists every class it was priced in, smallest first, with what one reservation covers in the class held and the term and payment options each is sold on. The rows are one class, the one recommended unless resource names another; resource on a row is the class bought, every quantity and demand are counted in it, held_units_equivalent is a row's quantity in the class held, and the contract is always priced in the class held. A class it was not priced in is a 422. quota says how many reservations the account may hold against what it would hold once the expiring ones are renewed; quota_blocked marks a renewal it could not hold in any class. size_mix is what the family runs by instance size and deployment. ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) - `quantity` (query, number, nullable): Price every offering at exactly this quantity. - `resource` (query, string, nullable): Read the renewal as this class, one of classes. Defaults to the class recommended. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `commitment_id` (string, required) - `sizing_basis` (string, required) - `decision` (one of `renew`, `let_expire`, default `renew`) - `quoted_at` (string, nullable) - `held_quantity` (number, required) - `held_resource` (string, nullable) - `held_quantity_as_bought` (number, nullable) - `classes` (array of object) - `resource` (string, nullable) - `held_units_per_one` (number, required) - `recommended` (boolean, default `false`) - `offered` (array of object) - `offering_id` (string, required) - `term_months` (integer, required) - `payment_option` (string, nullable) - `contract` (object, nullable) - `offering_id` (string, required) - `resource` (string, nullable) - `term_months` (integer, required) - `payment_option` (string, nullable) - `offering_class` (string, nullable) - `reservation_scope` (string, nullable) - `currency` (string, default `USD`) - `upfront` (number, nullable) - `recurring_hourly` (number, nullable) - `quantity` (number, nullable) - `term_total` (number, nullable) - `all_in` (number, nullable) - `per_year` (number, nullable) - `idle_units` (number, nullable) - `spill_units` (number, nullable) - `spill_cost` (number, nullable) - `recommended` (boolean, default `false`) - `held_units_equivalent` (number, nullable) - `quota_blocked` (boolean, default `false`) - `options` (array of object) - `offering_id` (string, required) - `resource` (string, nullable) - `term_months` (integer, required) - `payment_option` (string, nullable) - `offering_class` (string, nullable) - `reservation_scope` (string, nullable) - `currency` (string, default `USD`) - `upfront` (number, nullable) - `recurring_hourly` (number, nullable) - `quantity` (number, nullable) - `term_total` (number, nullable) - `all_in` (number, nullable) - `per_year` (number, nullable) - `idle_units` (number, nullable) - `spill_units` (number, nullable) - `spill_cost` (number, nullable) - `recommended` (boolean, default `false`) - `held_units_equivalent` (number, nullable) - `quota_blocked` (boolean, default `false`) - `quantity_step` (number, required) - `demand` (object, nullable) - `basis` (string, required) - `points` (array of object) - `date` (string, required) - `units` (number, required) - `units_per_month` (number, required) - `on_demand_rate` (number, nullable) - `on_demand_floor` (number, nullable) - `projection` (array of object) - `date` (string, required) - `units` (number, required) - `quota` (object, nullable) - `used` (integer, required) - `limit` (integer, required) - `projected` (integer, required) - `size_mix` (array of object) - `instance_type` (string, required) - `deployment` (string, required) - `instances` (number, required) - `normalized_units` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Renewal plan for one commitment Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-renewal-plan-by-id.md ## GET /api/v1/commitments/renewal-plan/{commitment_id} What to repurchase and when. units_recommended is the quantity the renewal options recommend, the same number the drawer shows: 0 with decision let_expire when the commitment was used too little to pay for itself, the quantity held when nothing measured it. units_consumed is the average the sized series measured, null when unmeasured. States the instant to buy after rather than a date, because a replacement bills from the moment it is bought, and lists the pending changes to the same instance family that must land first so their capacity is not reserved for another term. superseded_by names the commitment that already renewed this one, and no_recent_usage says nothing of its family and engine ran last week; either one means no renewal is raised. ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `commitment_id` (string, required) - `service` (string, required) - `holder_account_id` (string, required) - `end_at` (string, nullable, required) - `end_at_assumed` (boolean, default `false`) - `buy_after_utc` (string, nullable, required) - `expires_in_seconds` (integer, nullable, required) - `renewal_window_days` (integer, required) - `units_held` (number, required) - `units_consumed` (number, nullable, required) - `units_recommended` (number, required) - `resource_recommended` (string, nullable) - `quantity_recommended` (number, nullable) - `sizing_basis` (string, default `unmeasured`) - `decision` (one of `renew`, `let_expire`, default `renew`) - `protects_monthly` (number, required) - `rightsizing_monthly` (number, required) - `pending_changes` (array of object, required) - `recommendation_id` (string, required) - `service` (string, required) - `account` (string, required) - `monthly_savings` (number, required) - `status` (string, required) - `blocking_changes` (array of object) - `recommendation_id` (string, required) - `service` (string, required) - `account` (string, required) - `monthly_savings` (number, required) - `status` (string, required) - `exchangeable` (boolean, nullable, required) - `cancellable` (boolean, nullable, required) - `superseded_by` (string, nullable) - `superseded_at` (string, nullable) - `no_recent_usage` (boolean, default `false`) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # What replacing this commitment costs today Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-renewal-quote-by-id.md ## GET /api/v1/commitments/renewal-quote/{commitment_id} Priced from the current offering rather than from the expiring term, whose price is as old as the term. error_message carries a lookup that failed, so an unpriced renewal reads as unpriced rather than as zero. ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `commitment_id` (string, required) - `offering_id` (string, nullable) - `term_months` (integer, nullable) - `payment_option` (string, nullable) - `qty` (integer, nullable) - `currency` (string, nullable) - `fixed_price` (number, nullable) - `recurring_hourly` (number, nullable) - `usage_hourly` (number, nullable) - `upfront` (number, nullable) - `commitment_hourly` (number, nullable) - `error_message` (string, nullable) - `quoted_at` (string, nullable) - `term` (object, nullable) - `currency` (string, required) - `term_hours` (integer, required) - `upfront_total` (number, required) - `recurring_hourly_total` (number, required) - `term_total` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Uncovered on-demand base, split for sizing Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-uncovered.md ## GET /api/v1/commitments/uncovered On-demand spend a Savings Plan could cover and no commitment does, per payer, plan type, service and platform, read off the daily CUR over the last 30 complete days. Carries the daily floor of each slice, in commitment dollars an hour, and names each row's grain and source. A payer with no CUR gets Cost Explorer's hourly figures as visibility, with no suggested size. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `service` (string, required) - `organization_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database`, required) - `platform` (string, required) - `on_demand_hourly_avg` (number, required) - `on_demand_hourly_min` (number, required) - `volatility_ratio` (number, required) - `recommended_kind` (string, nullable, required) - `suggested_commitment_hourly` (number, required) - `grain` (one of `daily`, `hourly`, required) - `source` (one of `cur`, `cost_explorer_recommendation`, required) - `window_days` (integer, required) - `fetched_on` (date string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Utilization for one instrument, optionally for one service Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments-utilization.md ## GET /api/v1/commitments/utilization One entry per service for reservations, or per plan type for plans, each measured on its own and never rolled up: EC2 and RDS contract in vCPU while Redshift, ElastiCache, OpenSearch, Elasticsearch and MemoryDB contract in nodes, so an average across them describes neither. Each entry's headline divides the used hours by the purchased hours over the window rather than averaging its dimensions. ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, default `aws`) - `type` (query, string, default `ri`) - `start` (query, string, nullable) - `end` (query, string, nullable) - `granularity` (query, string, default `daily`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `instrument` (one of `ri`, `sp`, required) - `services` (array of object, required) - `service` (string, required) - `service_label` (string, required) - `utilization_pct` (number, required) - `dimensions` (array of object, required) - `key` (one of `capacity`, `memory`, required) - `used` (number, required) - `total` (number, required) - `unit` (string, required) - `utilization_pct` (number, required) - `committed_monthly_usd` (number, nullable, required) - `committed_hourly_usd` (number, nullable, required) - `contracted_hourly_usd` (number, nullable, required) - `applied_to` (array of object, required) - `service` (string, required) - `hourly_usd` (number, required) - `days` (array of object, required) - `date` (string, required) - `capacity_pct` (number, nullable) - `capacity_used` (number, nullable) - `capacity_total` (number, nullable) - `commitment_pct` (number, nullable) - `commitment_used_usd` (number, nullable) - `commitment_total_usd` (number, nullable) - `coverage_pct` (number, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List commitments Source: https://docs.levelfour.ai/api-reference/commitments/get-commitments.md ## GET /api/v1/commitments ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, nullable) - `kind` (query, string, nullable): AWS writes reserved_instance and savings_plan; the Google Cloud CUD worker writes committed_use_discount and spend_commitment into the same table. - `status` (query, string, nullable) - `page` (query, integer, default `1`) - `page_size` (query, integer, default `20`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `id` (string, required) - `provider` (string, required) - `service` (string, required) - `service_label` (string, required) - `account_id` (string, required) - `account_name` (string, required) - `region` (string, required) - `kind` (one of `reserved_instance`, `savings_plan`, `committed_use_discount`, `spend_commitment`, required) - `start_date` (string, required) - `end_date` (string, required) - `status` (one of `active`, `expiring`, `expired`, `cancelled`, required) - `current_utilization_pct` (number, required) - `current_coverage_pct` (number, required) - `monthly_commitment_usd` (number, required) - `pagination` (object, required) - `total_items` (integer, required) - `total_pages` (integer, required) - `current_page` (integer, required) - `page_size` (integer, required) - `has_next` (boolean, required) - `has_previous` (boolean, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Re-size a purchase nobody has decided yet Source: https://docs.levelfour.ai/api-reference/commitments/patch-commitments-purchase-by-id.md ## PATCH /api/v1/commitments/purchase/{recommendation_id} A body naming kind re-sizes a purchase raised as a NewPurchase: its size, term, payment option and offering, looked up again in AWS. Its target and buying account stay as raised (409 for another). A body without kind points a purchase raised from its proposal at another profile or commitment_hourly, sized on today's proposal; naming neither re-sizes the pick it was raised on. Each body fits only its own kind of purchase (409 otherwise). Keeps the id. 404 for an id that is no purchase, 409 once anybody has accepted, rejected or requested it, and 409 for any refusal a raise meets. Answers rebound true. A draft is re-sized by the person who raised it alone (404 for anybody else), which renews its expiry, and 409 purchase_draft_expired once it has expired. ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Request body Required. Content type `application/json`. - Variant 1: object - Variant 1: ReservationPurchase - `kind` (`reserved_instance`, required) - `service` (one of `ec2`, `rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`, required) - `region` (string, required) - `engine` (string, nullable) - `deployment` (one of `single_az`, `multi_az`, nullable) - `offering_class` (one of `standard`, `convertible`, nullable) - `tenancy` (one of `default`, `dedicated`, nullable) - `resource` (string, required) - `quantity` (integer, required) - `term_months` (one of `12`, `36`, required) - `payment_option` (one of `no_upfront`, `partial_upfront`, `all_upfront`, required) - `offering_id` (string, nullable) - `buying_account_id` (string, required) - Variant 2: PlanPurchase - `kind` (`savings_plan`, required) - `plan_type` (one of `compute`, `database`, `ec2_instance`, required) - `instance_family` (string, nullable) - `region` (string, nullable) - `commitment_hourly` (number, required) - `term_months` (one of `12`, `36`, required) - `payment_option` (one of `no_upfront`, `partial_upfront`, `all_upfront`, required) - `offering_id` (string, nullable) - `buying_account_id` (string, required) - Variant 2: PurchaseSize - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable) - `commitment_hourly` (number, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database` or `ec2_instance`, nullable, required): Null for a reservation - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `commitment_hourly` (number, nullable, required): Null for a reservation - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `capped_by` (array of string, required) - `monthly_savings` (number, required) - `grain` (`daily`, nullable, required): Null when no usage was replayed to price the purchase - `source` (`cur`, nullable, required) - `created_by` (string, nullable) - `created_at` (string, required) - `rebindable` (boolean, default `false`) - `kind` (one of `reserved_instance`, `savings_plan`, nullable): Null on a plan raised from its proposal, before either instrument had terms - `origin` (one of `recommended`, `custom`, nullable) - `target` (object, nullable): The ReservationTarget or PlanTarget the purchase was raised for - `resource` (string, nullable): The class a reservation buys - `quantity` (integer, nullable): How many reservations it buys - `offering_id` (string, nullable) - `quote` (object, nullable): The PurchaseQuoteData it was raised on, without its series - `draft` (boolean, default `false`): Raised before anybody asked for a decision on it. A draft is its raiser's alone: out of every list and total until it is accepted or requested - `draft_expires_at` (string, nullable): When a draft dies unless it is accepted or requested first. Null for a purchase that is no draft - `created` (boolean, default `false`) - `rebound` (boolean, default `false`) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Buy a released purchase now instead of after its hold Source: https://docs.levelfour.ai/api-reference/commitments/post-commitments-purchase-by-id-buy-now.md ## POST /api/v1/commitments/purchase/{recommendation_id}/buy-now A new purchase released to Automated Savings waits up to 24 hours before LevelFour sends it to AWS, so it can still be cancelled. This ends the wait: the executor sends it on its next run, within about five minutes. Organization admins only. Answers where the purchase stands; calling it again changes nothing. 409 purchase_not_held for a purchase that is not released to Automated Savings, tranche_keeps_its_day for a tranche of a purchase schedule, purchase_cancelled once it was cancelled, and purchase_already_sent once it was sent. 404 for an id that is no purchase. ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database` or `ec2_instance`, nullable, required): Null for a reservation - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `commitment_hourly` (number, nullable, required): Null for a reservation - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `capped_by` (array of string, required) - `monthly_savings` (number, required) - `grain` (`daily`, nullable, required): Null when no usage was replayed to price the purchase - `source` (`cur`, nullable, required) - `created_by` (string, nullable) - `created_at` (string, required) - `rebindable` (boolean, default `false`) - `kind` (one of `reserved_instance`, `savings_plan`, nullable): Null on a plan raised from its proposal, before either instrument had terms - `origin` (one of `recommended`, `custom`, nullable) - `target` (object, nullable): The ReservationTarget or PlanTarget the purchase was raised for - `resource` (string, nullable): The class a reservation buys - `quantity` (integer, nullable): How many reservations it buys - `offering_id` (string, nullable) - `quote` (object, nullable): The PurchaseQuoteData it was raised on, without its series - `draft` (boolean, default `false`): Raised before anybody asked for a decision on it. A draft is its raiser's alone: out of every list and total until it is accepted or requested - `draft_expires_at` (string, nullable): When a draft dies unless it is accepted or requested first. Null for a purchase that is no draft - `buying_account_id` (string, required) - `schedule_id` (string, nullable) - `tranche_number` (integer, nullable) - `tranches_total` (integer, nullable) - `buy_after_utc` (string, nullable) - `verified_active_at` (string, nullable): When the executor saw AWS report the plan active - `execution` (object, required) - `status` (one of `not_started`, `in_progress`, `active`, `queued`, `payment_failed`, `failed`, `indeterminate`, `scheduled`, `cancelled_before_purchase`, `returned`, `cancelled`, required) - `provider_state` (string, nullable): AWS's own state for the plan, once it was sent - `purchase_id` (string, nullable): The Savings Plan id AWS answered with - `error` (string, nullable) - `starts_at` (string, nullable): When AWS starts a plan it was asked to queue. Only purchases sent before LevelFour stopped queueing inside AWS carry one. - `send_at` (string, nullable): When LevelFour sends a released purchase to AWS: once its hold ends, or on its scheduled day for a tranche. Until then it reads scheduled, and it can be bought now or cancelled. - `can_buy_now` (boolean, default `false`): The caller may end its hold now: an organization admin, on a new purchase released to Automated Savings that is still held and was not sent. - `can_cancel` (boolean, default `false`): The caller may cancel it now: an organization admin or the member who raised or requested it, while it was not sent to AWS. - `bought_outside` (object, nullable): The plan of the same type and term bought outside LevelFour that stopped this one - `plan_id` (string, required) - `started_on` (string, nullable) - `trigger_info` (object, nullable) - `execution_steps` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `duration_seconds` (integer, nullable) - `parent_step_id` (string, nullable) - `execution_logs` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `commands` (array of object) - `order` (integer, required) - `command` (string, required) - `output` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Cancel a purchase before it is sent to AWS Source: https://docs.levelfour.ai/api-reference/commitments/post-commitments-purchase-by-id-cancel.md ## POST /api/v1/commitments/purchase/{recommendation_id}/cancel Stops a purchase that has not been sent to AWS: it is rejected, reads cancelled_before_purchase, and stays that way, so it cannot be accepted or released again; raise a new purchase to buy it. An organization admin or the member who raised or requested it may cancel. Calling it again changes nothing. 409 purchase_already_sent once a purchase was sent and may have been bought, purchase_closed for one already bought or rejected, and purchase_bought_by_hand for one released to be bought by hand, which may already be bought. 404 for an id that is no purchase. ### Authentication Send `Authorization: Bearer `. ### Parameters - `recommendation_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database` or `ec2_instance`, nullable, required): Null for a reservation - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `commitment_hourly` (number, nullable, required): Null for a reservation - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `capped_by` (array of string, required) - `monthly_savings` (number, required) - `grain` (`daily`, nullable, required): Null when no usage was replayed to price the purchase - `source` (`cur`, nullable, required) - `created_by` (string, nullable) - `created_at` (string, required) - `rebindable` (boolean, default `false`) - `kind` (one of `reserved_instance`, `savings_plan`, nullable): Null on a plan raised from its proposal, before either instrument had terms - `origin` (one of `recommended`, `custom`, nullable) - `target` (object, nullable): The ReservationTarget or PlanTarget the purchase was raised for - `resource` (string, nullable): The class a reservation buys - `quantity` (integer, nullable): How many reservations it buys - `offering_id` (string, nullable) - `quote` (object, nullable): The PurchaseQuoteData it was raised on, without its series - `draft` (boolean, default `false`): Raised before anybody asked for a decision on it. A draft is its raiser's alone: out of every list and total until it is accepted or requested - `draft_expires_at` (string, nullable): When a draft dies unless it is accepted or requested first. Null for a purchase that is no draft - `buying_account_id` (string, required) - `schedule_id` (string, nullable) - `tranche_number` (integer, nullable) - `tranches_total` (integer, nullable) - `buy_after_utc` (string, nullable) - `verified_active_at` (string, nullable): When the executor saw AWS report the plan active - `execution` (object, required) - `status` (one of `not_started`, `in_progress`, `active`, `queued`, `payment_failed`, `failed`, `indeterminate`, `scheduled`, `cancelled_before_purchase`, `returned`, `cancelled`, required) - `provider_state` (string, nullable): AWS's own state for the plan, once it was sent - `purchase_id` (string, nullable): The Savings Plan id AWS answered with - `error` (string, nullable) - `starts_at` (string, nullable): When AWS starts a plan it was asked to queue. Only purchases sent before LevelFour stopped queueing inside AWS carry one. - `send_at` (string, nullable): When LevelFour sends a released purchase to AWS: once its hold ends, or on its scheduled day for a tranche. Until then it reads scheduled, and it can be bought now or cancelled. - `can_buy_now` (boolean, default `false`): The caller may end its hold now: an organization admin, on a new purchase released to Automated Savings that is still held and was not sent. - `can_cancel` (boolean, default `false`): The caller may cancel it now: an organization admin or the member who raised or requested it, while it was not sent to AWS. - `bought_outside` (object, nullable): The plan of the same type and term bought outside LevelFour that stopped this one - `plan_id` (string, required) - `started_on` (string, nullable) - `trigger_info` (object, nullable) - `execution_steps` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `duration_seconds` (integer, nullable) - `parent_step_id` (string, nullable) - `execution_logs` (array of object) - `step_id` (string, required) - `label` (string, required) - `status` (one of `done`, `pending`, `warning`, `in_progress`, `failed`, required) - `commands` (array of object) - `order` (integer, required) - `command` (string, required) - `output` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # What a new reservation or Savings Plan would cost and save Source: https://docs.levelfour.ai/api-reference/commitments/post-commitments-purchase-simulation.md ## POST /api/v1/commitments/purchase-simulation Quotes one purchase the organization does not hold yet, a NewPurchase with an optional lookback_days of 30 or 60. basis is usage when the buying account's payer already runs the target: a reservation is replayed over its family's daily on-demand hours in units of the class bought and priced at the on-demand rate measured on the bill; a plan is replayed at the organization's own rates, as GET /purchase-simulation does, on any term and payment AWS sells it on. Otherwise basis is full_use: every hour used, against the price list's on-demand rate where it has one, and at face (no saving) where it has none, which is always the case for a plan. upfront, recurring_monthly, commitment_monthly and term_total are the offering's list price; over_automation_cap says Automated Savings would refuse the term total. A reservation quoted on usage also answers unused_units and left_on_demand_units, what it leaves unused and on demand an hour in reservations of the class bought. The offering is the one named, or the cheapest sold on those terms. A plan quoted without an offering_id is priced on its commitment, term and payment option without reading the catalogue, and its offering_id is null. 422 when AWS sells nothing on those terms, and for a Database plan other than 12 months No Upfront; 502 when every connected account refused the offerings lookup. Buys nothing. ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - Variant 1: ReservationQuoteRequest - `kind` (`reserved_instance`, required) - `service` (one of `ec2`, `rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`, required) - `region` (string, required) - `engine` (string, nullable) - `deployment` (one of `single_az`, `multi_az`, nullable) - `offering_class` (one of `standard`, `convertible`, nullable) - `tenancy` (one of `default`, `dedicated`, nullable) - `resource` (string, required) - `quantity` (integer, required) - `term_months` (one of `12`, `36`, required) - `payment_option` (one of `no_upfront`, `partial_upfront`, `all_upfront`, required) - `offering_id` (string, nullable) - `buying_account_id` (string, required) - `lookback_days` (one of `30`, `60`, default `60`) - Variant 2: PlanQuoteRequest - `kind` (`savings_plan`, required) - `plan_type` (one of `compute`, `database`, `ec2_instance`, required) - `instance_family` (string, nullable) - `region` (string, nullable) - `commitment_hourly` (number, required) - `term_months` (one of `12`, `36`, required) - `payment_option` (one of `no_upfront`, `partial_upfront`, `all_upfront`, required) - `offering_id` (string, nullable) - `buying_account_id` (string, required) - `lookback_days` (one of `30`, `60`, default `60`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `basis` (one of `usage`, `full_use`, required) - `offering_id` (string, nullable, required): The offering priced. Null for a Savings Plan quoted without one: a plan costs its commitment on its term and payment option, so it is priced on those without reading the catalogue - `currency` (string, required) - `term_months` (integer, required) - `payment_option` (string, required) - `upfront` (number, required) - `recurring_monthly` (number, required) - `commitment_monthly` (number, required) - `term_total` (number, required) - `covered_on_demand_monthly` (number, required) - `left_on_demand_monthly` (number, required) - `unused_monthly` (number, required) - `unused_units` (number, nullable): Reservations of the class bought left unused, an hour on average over the window. Null at full use and for a Savings Plan - `left_on_demand_units` (number, nullable): Usage left on demand, an hour on average over the window, in reservations of the class bought. Null at full use and for a Savings Plan - `net_savings_monthly` (number, required) - `savings_pct` (number, required) - `coverage_before_pct` (number, nullable, required) - `coverage_after_pct` (number, nullable, required) - `utilization_pct` (number, nullable, required) - `break_even_months` (integer, nullable, required) - `capacity` (number, required) - `over_automation_cap` (boolean, required) - `pool` (object, nullable, required) - `window` (object, required) - `first_day` (date string, required) - `last_day` (date string, required) - `days` (integer, required) - `missing_days` (integer, required) - `last_loaded` (date string, required) - `recommended` (number, nullable, required) - `recommended_capacity` (number, nullable, required) - `already_covered_monthly` (number, required) - `agreed_hourly` (number, default `0`) - `series` (array of object, required) - `day` (date string, required) - `used` (number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Raise a Reserved Instance or Savings Plan purchase Source: https://docs.levelfour.ai/api-reference/commitments/post-commitments-purchase.md ## POST /api/v1/commitments/purchase Raises a purchase as a savings recommendation, BUY- followed by a number. Nothing is bought until an organization admin releases it, so a member may raise one, and so may a read-write API key. A body naming kind is a NewPurchase: a reservation (service, region, class, quantity) or a Savings Plan of any type (commitment_hourly, and for an EC2 Instance plan its instance_family and region), on a 12 or 36 month term and any payment option. offering_id is required: it is looked up again in AWS and refused with 409 once AWS no longer sells it. buying_account_id must be an AWS account of the organization (422 otherwise). monthly_savings is the quote's net savings when it was replayed over measured usage, and 0 when it assumes every hour is used. Calling it again for the same target returns the undecided purchase when it names the same pick. Any other pick is answered 409 undecided_purchase_exists with that purchase's recommendation_id, state, raised_by and raised_at, and confirm_additional true raises a second one beside it. draft true raises a purchase before anybody asks for a decision on it, so an access grant can be minted for its id. A draft is its raiser's alone: it is left out of every savings list and total, it is never the purchase already raised for its target, and it becomes an ordinary purchase the moment it is accepted or an execution request is filed for it. It expires at draft_expires_at, which is renewed when it is re-sized and moved to its access grant's end when that is later, and an expired draft is refused with 409 purchase_draft_expired wherever it is acted on. Raising a draft again for the same target and buying account returns the caller's own draft, re-sized to the new pick with rebound true, so it keeps its id and its grant. Another person's draft is never returned and never refuses a raise, so accepting a draft or filing its execution request asks again what a raise asks: 409 schedule_exists, same_purchase_exists or undecided_purchase_exists, the last two unless the draft was raised again with confirm_additional true. A draft takes a signed-in caller and a body naming kind (422 otherwise). A body without kind raises one payer's /purchase-plan proposal for a 12 month No Upfront plan, as before. profile picks a sized profile (balanced when neither is named), or commitment_hourly names a size of the caller's own, rounded down to $0.001 and refused above AWS's cap. Calling it again returns the purchase nobody has decided yet for that payer and plan type when it is bound to the same pick, and refuses any other pick with 409: PATCH /purchase/{recommendation_id} changes it. Also 409 when the proposal carries no size, when AWS's own recommendation was not fetched in the last 7 days, or when the size is under $0.001 an hour. payer_account_id may be omitted when the organization has one payer (422 otherwise; 404 for a payer that bills nothing). monthly_savings is the lower of the daily replay and AWS's estimate scaled to this size, both at the organization's own rates. attempt_id names one attempt at a purchase: sending the same attempt_id again, after a second click, a reload or a retry, returns the purchase that attempt raised with created false, and a different request under an attempt_id already used is refused with 422. A new attempt identical to a purchase awaiting approval, accepted, released, or sent to AWS in the last 24 hours is answered 409 same_purchase_exists with that purchase's recommendation_id, state, raised_by and raised_at; send it again with confirm_additional true to raise a second one. Any other difference, a quantity or a commitment for one, raises as usual. Either way, when the buying account is connected the row carries both implementation tags and an access policy that can only buy this one purchase, so Automated Savings can buy it; otherwise it is delivered by hand (Manual Setup) with no access policy. It bills only once AWS reports it active. ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - Variant 1: object - Variant 1: ReservationPurchaseRaise - `attempt_id` (uuid string, nullable) - `confirm_additional` (boolean, default `false`) - `draft` (boolean, default `false`) - `kind` (`reserved_instance`, required) - `service` (one of `ec2`, `rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`, required) - `region` (string, required) - `engine` (string, nullable) - `deployment` (one of `single_az`, `multi_az`, nullable) - `offering_class` (one of `standard`, `convertible`, nullable) - `tenancy` (one of `default`, `dedicated`, nullable) - `resource` (string, required) - `quantity` (integer, required) - `term_months` (one of `12`, `36`, required) - `payment_option` (one of `no_upfront`, `partial_upfront`, `all_upfront`, required) - `offering_id` (string, nullable) - `buying_account_id` (string, required) - Variant 2: PlanPurchaseRaise - `attempt_id` (uuid string, nullable) - `confirm_additional` (boolean, default `false`) - `draft` (boolean, default `false`) - `kind` (`savings_plan`, required) - `plan_type` (one of `compute`, `database`, `ec2_instance`, required) - `instance_family` (string, nullable) - `region` (string, nullable) - `commitment_hourly` (number, required) - `term_months` (one of `12`, `36`, required) - `payment_option` (one of `no_upfront`, `partial_upfront`, `all_upfront`, required) - `offering_id` (string, nullable) - `buying_account_id` (string, required) - Variant 2: ProposalPurchaseRaise - `attempt_id` (uuid string, nullable) - `confirm_additional` (boolean, default `false`) - `draft` (boolean, default `false`) - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable) - `commitment_hourly` (number, nullable) - `plan_type` (one of `compute`, `database`, required) - `payer_account_id` (string, nullable) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `recommendation_id` (string, required) - `payer_account_id` (string, required) - `plan_type` (one of `compute`, `database` or `ec2_instance`, nullable, required): Null for a reservation - `term_months` (integer, required) - `payment_option` (one of `all_upfront`, `partial_upfront`, `no_upfront`, required) - `commitment_hourly` (number, nullable, required): Null for a reservation - `profile` (one of `conservative`, `balanced`, `max_savings`, nullable, required) - `capped_by` (array of string, required) - `monthly_savings` (number, required) - `grain` (`daily`, nullable, required): Null when no usage was replayed to price the purchase - `source` (`cur`, nullable, required) - `created_by` (string, nullable) - `created_at` (string, required) - `rebindable` (boolean, default `false`) - `kind` (one of `reserved_instance`, `savings_plan`, nullable): Null on a plan raised from its proposal, before either instrument had terms - `origin` (one of `recommended`, `custom`, nullable) - `target` (object, nullable): The ReservationTarget or PlanTarget the purchase was raised for - `resource` (string, nullable): The class a reservation buys - `quantity` (integer, nullable): How many reservations it buys - `offering_id` (string, nullable) - `quote` (object, nullable): The PurchaseQuoteData it was raised on, without its series - `draft` (boolean, default `false`): Raised before anybody asked for a decision on it. A draft is its raiser's alone: out of every list and total until it is accepted or requested - `draft_expires_at` (string, nullable): When a draft dies unless it is accepted or requested first. Null for a purchase that is no draft - `created` (boolean, default `false`) - `rebound` (boolean, default `false`) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Raise the purchase that replaces this commitment Source: https://docs.levelfour.ai/api-reference/commitments/post-commitments-renewal-by-id.md ## POST /api/v1/commitments/renewal/{commitment_id} Returns the recommendation that buys the new term, and the offering and quantity it is bound to. Calling it again never raises a rival: with no choice it returns the first one, and with a different choice it points a renewal nobody has decided on at that choice (rebound). rebindable says whether what it buys can still change: once anybody accepts, rejects or requests it, only the choice it was decided on is accepted. A renewal rejected before anybody released it is closed, and raising again creates a new one while the rejected one stays as history. Nothing is bought here: the recommendation carries the offering, the quantity and the instant after which buying is safe, and the apply path acts on it. A commitment already renewed, or whose family and engine ran nothing last week, is refused with 409. A read-write API key may raise a renewal; releasing it needs an organization admin. ### Authentication Send `Authorization: Bearer `. ### Parameters - `commitment_id` (path, string, required) ### Request body Optional. Content type `application/json`. Type: object, nullable - `offering_id` (string, nullable) - `quantity` (number, nullable) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `commitment_id` (string, required) - `recommendation_id` (string, required) - `created_by` (string, nullable) - `created_at` (string, nullable) - `created` (boolean, default `false`) - `rebound` (boolean, default `false`) - `rebindable` (boolean, default `false`) - `closed` (boolean, default `false`) - `offering_id` (string, nullable) - `quantity` (number, nullable) - `blocking_changes` (array of object) - `recommendation_id` (string, required) - `service` (string, required) - `account` (string, required) - `monthly_savings` (number, required) - `status` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Anomalies Source: https://docs.levelfour.ai/api-reference/anomalies.md - [Get anomalies summary](/api-reference/anomalies/get-anomalies-summary) - [List anomalies](/api-reference/anomalies/get-anomalies) - [Get anomaly detail](/api-reference/anomalies/get-anomalies-by-id) - [Who hears about detected spikes](/api-reference/anomalies/get-anomalies-notifications-preference) - [Set who hears about detected spikes](/api-reference/anomalies/put-anomalies-notifications-preference) --- # Get anomaly detail Source: https://docs.levelfour.ai/api-reference/anomalies/get-anomalies-by-id.md ## GET /api/v1/anomalies/{anomaly_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `anomaly_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `severity` (one of `critical`, `warning`, required) - `status` (one of `active`, `resolved`, `dismissed`, required) - `status_elapsed` (string, required) - `title` (string, required) - `subtitle` (string, required) - `stats` (array of object, required) - `id` (string, required) - `label` (string, required) - `value` (string, required) - `sub` (string, required) - `value_color` (one of `default`, `success`, `warning`, `destructive`, nullable) - `chart` (object, required) - `data` (array of object, required) - `index` (string, required) - `cost` (number, required) - `baseline_low` (number, required) - `baseline_high` (number, required) - `spike_index` (integer, required) - `spike_label` (string, required) - `deploy_index` (integer, nullable) - `deploy_label` (string, nullable) - `rca` (array of object, required) - `id` (string, required) - `rank` (string, required) - `service` (string, required): Service value from AWS CE GetAnomalies RootCauses[].Service. - `linked_account` (string, required): LinkedAccount value from AWS CE GetAnomalies RootCauses[].LinkedAccount. - `linked_account_name` (string, nullable): LinkedAccountName from AWS CE GetAnomalies RootCauses[].LinkedAccountName, present when AWS can resolve the member account name. - `region` (string, required): Region value from AWS CE GetAnomalies RootCauses[].Region. - `usage_type` (string, required): UsageType value from AWS CE GetAnomalies RootCauses[].UsageType. - `contribution` (number, nullable): Dollar amount this root cause contributed to the anomaly TotalImpact, from AWS CE GetAnomalies RootCauses[].Impact.Contribution. - `primary` (boolean, default `false`) - `resources` (array of object, required) - `id` (string, required) - `name` (string, required) - `cost` (number, required) - `delta_pct` (number, required) - `peak_impact_usd` (number, nullable): Single-day peak cost impact in USD, from AWS Cost Explorer Impact.MaxImpact. - `total_impact_usd` (number, nullable): Total accumulated cost impact in USD, from AWS Cost Explorer Impact.TotalImpact. - `actual_spend_usd` (number, nullable): Cumulative actual spend over the anomaly window, from AWS Cost Explorer Impact.TotalActualSpend. - `expected_spend_usd` (number, nullable): Cumulative expected (baseline) spend the model predicted, from AWS Cost Explorer Impact.TotalExpectedSpend. - `spike_pct` (number, nullable): Spike size as a percentage, from AWS Cost Explorer Impact.TotalImpactPercentage. - `anomaly_score` (number, nullable): AWS Cost Explorer AnomalyScore.CurrentScore (0.00-1.00), the model's latest confidence in the anomaly. - `max_score` (number, nullable): AWS Cost Explorer AnomalyScore.MaxScore (0.00-1.00), the peak confidence observed over the anomaly window. - `feedback` (one of `YES`, `NO`, `PLANNED_ACTIVITY`, nullable): Latest feedback recorded for this anomaly (YES confirmed, NO not an anomaly, PLANNED_ACTIVITY expected), mirrored to AWS Cost Explorer. - `anomaly_start_date` (string, nullable): ISO date string for when the anomaly window opened, from AWS Cost Explorer. - `anomaly_end_date` (string, nullable): ISO date string for when the anomaly window closed, from AWS Cost Explorer. Null while the anomaly is still ongoing. #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Who hears about detected spikes Source: https://docs.levelfour.ai/api-reference/anomalies/get-anomalies-notifications-preference.md ## GET /api/v1/anomalies/notifications/preference ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, nullable) - `threshold_usd` (string, nullable) - `recipients` (array of object) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `enabled` (boolean, default `true`) - `id` (string, required) - `created_by` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Get anomalies summary Source: https://docs.levelfour.ai/api-reference/anomalies/get-anomalies-summary.md ## GET /api/v1/anomalies/summary ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `active_count` (integer, required) - `delta_count` (integer, required) - `critical_count` (integer, required) - `warning_count` (integer, required) - `sparkline` (array of number, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List anomalies Source: https://docs.levelfour.ai/api-reference/anomalies/get-anomalies.md ## GET /api/v1/anomalies ### Authentication Send `Authorization: Bearer `. ### Parameters - `provider` (query, string, nullable) - `status` (query, string, nullable) - `severity` (query, string, nullable) - `page` (query, integer, default `1`) - `page_size` (query, integer, default `20`) - `start` (query, date string, nullable): Only anomalies whose window starts on or after this date - `end` (query, date string, nullable): Only anomalies whose window starts on or before this date ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `id` (string, required) - `severity` (one of `critical`, `warning`, required) - `status` (one of `active`, `resolved`, `dismissed`, required) - `title` (string, required) - `subtitle` (string, nullable): Resource line shown on the anomalies table. Sourced from the AWS DimensionValue stored on the anomaly DB row. - `badge_label` (string, nullable) - `rca_emphasis` (string, nullable) - `rca_tail` (string, nullable) - `peak_impact_usd` (number, nullable): Single-day peak cost impact in USD, as reported by AWS Cost Explorer Impact.MaxImpact. - `total_impact_usd` (number, nullable): Total accumulated cost impact in USD, as reported by AWS Cost Explorer Impact.TotalImpact. - `spike_pct` (number, nullable): Spike size as a percentage, as reported by AWS Cost Explorer Impact.TotalImpactPercentage. - `actual_spend_usd` (number, nullable): Cumulative actual spend over the anomaly window, as reported by AWS Cost Explorer Impact.TotalActualSpend. - `expected_spend_usd` (number, nullable): Cumulative expected (baseline) spend the model predicted, as reported by AWS Cost Explorer Impact.TotalExpectedSpend. - `anomaly_score` (number, nullable): AWS Cost Explorer AnomalyScore.CurrentScore (0.00-1.00). Surfaces the model's confidence in the anomaly. - `max_score` (number, nullable): AWS Cost Explorer AnomalyScore.MaxScore (0.00-1.00). Peak confidence observed over the anomaly window. - `feedback` (one of `YES`, `NO`, `PLANNED_ACTIVITY`, nullable): Latest feedback recorded for this anomaly (YES confirmed, NO not an anomaly, PLANNED_ACTIVITY expected), mirrored to AWS Cost Explorer. - `anomaly_start_date` (string, nullable): ISO date string for when the anomaly window opened, as reported by AWS Cost Explorer. - `anomaly_end_date` (string, nullable): ISO date string for when the anomaly window closed, as reported by AWS Cost Explorer. Null while the anomaly is still ongoing. - `timestamp` (string, required) - `pagination` (object, required) - `total_items` (integer, required) - `total_pages` (integer, required) - `current_page` (integer, required) - `page_size` (integer, required) - `has_next` (boolean, required) - `has_previous` (boolean, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Set who hears about detected spikes Source: https://docs.levelfour.ai/api-reference/anomalies/put-anomalies-notifications-preference.md ## PUT /api/v1/anomalies/notifications/preference ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `threshold_usd` (number or string, nullable) - `recipients` (array of object) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `enabled` (boolean, default `true`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `data` (object, nullable) - `threshold_usd` (string, nullable) - `recipients` (array of object) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `enabled` (boolean, default `true`) - `id` (string, required) - `created_by` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Cost Alerts Source: https://docs.levelfour.ai/api-reference/cost-alerts.md - [List cost alerts](/api-reference/cost-alerts/get-cost-alerts) - [Create a cost alert](/api-reference/cost-alerts/post-cost-alerts) - [List what an alert can watch](/api-reference/cost-alerts/get-cost-alerts-sources): Declared before `/{alert_id}`, or FastAPI matches 'sources' as an id and 404s it. - [Alert rules this account's own history would support](/api-reference/cost-alerts/get-cost-alerts-suggestions-by-id) - [Read one cost alert](/api-reference/cost-alerts/get-cost-alerts-by-id) - [Update or pause a cost alert](/api-reference/cost-alerts/patch-cost-alerts-by-id) - [Delete a cost alert](/api-reference/cost-alerts/delete-cost-alerts-by-id) - [What this alert has said, newest first](/api-reference/cost-alerts/get-cost-alerts-by-id-events) --- # Delete a cost alert Source: https://docs.levelfour.ai/api-reference/cost-alerts/delete-cost-alerts-by-id.md ## DELETE /api/v1/cost-alerts/{alert_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `alert_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # What this alert has said, newest first Source: https://docs.levelfour.ai/api-reference/cost-alerts/get-cost-alerts-by-id-events.md ## GET /api/v1/cost-alerts/{alert_id}/events ### Authentication Send `Authorization: Bearer `. ### Parameters - `alert_id` (path, string, required) - `limit` (query, integer, default `50`) - `offset` (query, integer, default `0`) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `instance_key` (string, required) - `state` (one of `firing`, `resolved`, required) - `period` (string, required) - `as_of` (string, required) - `observed_usd` (string, required) - `threshold_usd` (string, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, required) - `notified` (boolean, required) - `created_at` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Read one cost alert Source: https://docs.levelfour.ai/api-reference/cost-alerts/get-cost-alerts-by-id.md ## GET /api/v1/cost-alerts/{alert_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `alert_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `name` (string, required) - `params` (object, required) - Variant 1: BudgetParams - `kind` (`budget`, default `budget`) - `budget` (string, required) - `reset` (one of `monthly`, `quarterly`, required) - `rules` (array of object, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 2: ChangesParams - `kind` (`changes`, default `changes`) - `amount` (string, required) - `comparison` (one of `same_days_previous_week`, `same_days_previous_month`, `previous_7_days`, required) - `floor` (string, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 3: CommitmentParams - `kind` (`commitment`, default `commitment`) - `watch` (one of `expiry`, `lapsed`, `utilization`, `saturation`, `contract_overage`, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `threshold` (string, default `30`) - `coverage_below` (string, default `80`) - `source_id` (string, required) - `recipients` (array of object, required) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `slack_channels` (array of object): The Slack channels this alert posts in, each once per firing - `id` (string, required) - `name` (string, required): The channel's name when the alert was saved, without the # - `slack_channel` (string, nullable): Deprecated and always null: read slack_channels - `enabled` (boolean, required) - `paused_by` (string, nullable) - `sent_to_me` (boolean, default `false`) - `watching` (array of object): The live state of each thing this alert watches. Empty until a sweep has measured it - `key` (string, required): Empty for a rule that watches one thing, the slice for a split rule, the commitment id for a commitment rule - `state` (one of `ok`, `pending`, `firing`, required): pending is a crossing waiting for a second sweep to agree; firing has been announced - `period` (string, nullable): The period the firing was announced for - `observed_usd` (number, nullable): The last figure measured: dollars for a spend rule, days or a percentage for a commitment rule #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List what an alert can watch Source: https://docs.levelfour.ai/api-reference/cost-alerts/get-cost-alerts-sources.md ## GET /api/v1/cost-alerts/sources Declared before `/{alert_id}`, or FastAPI matches 'sources' as an id and 404s it. ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `label` (string, required) - `group` (one of `accounts`, `views`, `commitments`, required) - `provider` (string, nullable): Cloud this source reports under. Null when it reads more than one. #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Alert rules this account's own history would support Source: https://docs.levelfour.ai/api-reference/cost-alerts/get-cost-alerts-suggestions-by-id.md ## GET /api/v1/cost-alerts/suggestions/{account_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `account_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `generated_at` (string, required) - `source` (object, required) - `provider` (string, required) - `account_id` (string, required) - `alias` (string, required) - `currency` (string, required) - `connected_at` (string, required) - `baseline` (object, required) - `months_available` (integer, required) - `last_closed_month` (string, required) - `last_closed_spend` (string, required) - `trailing_3m_avg` (string, required) - `resource_ids_available` (boolean, required) - `maturity` (one of `none`, `partial`, `full`, required) - `calibration` (array of object, required) - `deviation_pct` (integer, required) - `firings` (integer, required) - `suggestions` (array of object, required) - `id` (string, required) - `name` (string, required) - `params` (object, required) - `source_id` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List cost alerts Source: https://docs.levelfour.ai/api-reference/cost-alerts/get-cost-alerts.md ## GET /api/v1/cost-alerts ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `name` (string, required) - `params` (object, required) - Variant 1: BudgetParams - `kind` (`budget`, default `budget`) - `budget` (string, required) - `reset` (one of `monthly`, `quarterly`, required) - `rules` (array of object, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 2: ChangesParams - `kind` (`changes`, default `changes`) - `amount` (string, required) - `comparison` (one of `same_days_previous_week`, `same_days_previous_month`, `previous_7_days`, required) - `floor` (string, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 3: CommitmentParams - `kind` (`commitment`, default `commitment`) - `watch` (one of `expiry`, `lapsed`, `utilization`, `saturation`, `contract_overage`, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `threshold` (string, default `30`) - `coverage_below` (string, default `80`) - `source_id` (string, required) - `recipients` (array of object, required) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `slack_channels` (array of object): The Slack channels this alert posts in, each once per firing - `id` (string, required) - `name` (string, required): The channel's name when the alert was saved, without the # - `slack_channel` (string, nullable): Deprecated and always null: read slack_channels - `enabled` (boolean, required) - `paused_by` (string, nullable) - `sent_to_me` (boolean, default `false`) - `watching` (array of object): The live state of each thing this alert watches. Empty until a sweep has measured it - `key` (string, required): Empty for a rule that watches one thing, the slice for a split rule, the commitment id for a commitment rule - `state` (one of `ok`, `pending`, `firing`, required): pending is a crossing waiting for a second sweep to agree; firing has been announced - `period` (string, nullable): The period the firing was announced for - `observed_usd` (number, nullable): The last figure measured: dollars for a spend rule, days or a percentage for a commitment rule #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Update or pause a cost alert Source: https://docs.levelfour.ai/api-reference/cost-alerts/patch-cost-alerts-by-id.md ## PATCH /api/v1/cost-alerts/{alert_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `alert_id` (path, string, required) ### Request body Required. Content type `application/json`. - `name` (string, nullable) - `params` (object, nullable) - Variant 1: BudgetParams - `kind` (`budget`, default `budget`) - `budget` (number or string, required) - `reset` (one of `monthly`, `quarterly`, required) - `rules` (array of object, required) - `amount` (number or string, required) - `unit` (one of `percent`, `absolute`, required) - `window` (one of `within`, `any`, default `any`) - `days` (integer, nullable) - `reference` (one of `start_of_month`, `end_of_month`, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 2: ChangesParams - `kind` (`changes`, default `changes`) - `amount` (number or string, required) - `comparison` (one of `same_days_previous_week`, `same_days_previous_month`, `previous_7_days`, required) - `floor` (number or string, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 3: CommitmentParams - `kind` (`commitment`, default `commitment`) - `watch` (one of `expiry`, `lapsed`, `utilization`, `saturation`, `contract_overage`, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `threshold` (number or string, default `30`) - `coverage_below` (number or string, default `80`) - `source_id` (string, nullable) - `recipients` (array of object, nullable) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `slack_channel_ids` (array of string, nullable): Replaces the channels this alert posts in; an empty list takes them all off - `slack_channel` (string, nullable): Deprecated and never read: use slack_channel_ids - `enabled` (boolean, nullable) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `name` (string, required) - `params` (object, required) - Variant 1: BudgetParams - `kind` (`budget`, default `budget`) - `budget` (string, required) - `reset` (one of `monthly`, `quarterly`, required) - `rules` (array of object, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 2: ChangesParams - `kind` (`changes`, default `changes`) - `amount` (string, required) - `comparison` (one of `same_days_previous_week`, `same_days_previous_month`, `previous_7_days`, required) - `floor` (string, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 3: CommitmentParams - `kind` (`commitment`, default `commitment`) - `watch` (one of `expiry`, `lapsed`, `utilization`, `saturation`, `contract_overage`, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `threshold` (string, default `30`) - `coverage_below` (string, default `80`) - `source_id` (string, required) - `recipients` (array of object, required) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `slack_channels` (array of object): The Slack channels this alert posts in, each once per firing - `id` (string, required) - `name` (string, required): The channel's name when the alert was saved, without the # - `slack_channel` (string, nullable): Deprecated and always null: read slack_channels - `enabled` (boolean, required) - `paused_by` (string, nullable) - `sent_to_me` (boolean, default `false`) - `watching` (array of object): The live state of each thing this alert watches. Empty until a sweep has measured it - `key` (string, required): Empty for a rule that watches one thing, the slice for a split rule, the commitment id for a commitment rule - `state` (one of `ok`, `pending`, `firing`, required): pending is a crossing waiting for a second sweep to agree; firing has been announced - `period` (string, nullable): The period the firing was announced for - `observed_usd` (number, nullable): The last figure measured: dollars for a spend rule, days or a percentage for a commitment rule #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Create a cost alert Source: https://docs.levelfour.ai/api-reference/cost-alerts/post-cost-alerts.md ## POST /api/v1/cost-alerts ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `name` (string, required) - `params` (object, required) - Variant 1: BudgetParams - `kind` (`budget`, default `budget`) - `budget` (number or string, required) - `reset` (one of `monthly`, `quarterly`, required) - `rules` (array of object, required) - `amount` (number or string, required) - `unit` (one of `percent`, `absolute`, required) - `window` (one of `within`, `any`, default `any`) - `days` (integer, nullable) - `reference` (one of `start_of_month`, `end_of_month`, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 2: ChangesParams - `kind` (`changes`, default `changes`) - `amount` (number or string, required) - `comparison` (one of `same_days_previous_week`, `same_days_previous_month`, `previous_7_days`, required) - `floor` (number or string, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 3: CommitmentParams - `kind` (`commitment`, default `commitment`) - `watch` (one of `expiry`, `lapsed`, `utilization`, `saturation`, `contract_overage`, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `threshold` (number or string, default `30`) - `coverage_below` (number or string, default `80`) - `source_id` (string, required) - `recipients` (array of object, required) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `slack_channel_ids` (array of string, nullable): Slack channel ids to post in, public or private. Omitted, the alert takes the organization's default channel when one is set; an empty list posts in no channel - `slack_channel` (string, nullable): Deprecated and never read: use slack_channel_ids ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `name` (string, required) - `params` (object, required) - Variant 1: BudgetParams - `kind` (`budget`, default `budget`) - `budget` (string, required) - `reset` (one of `monthly`, `quarterly`, required) - `rules` (array of object, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 2: ChangesParams - `kind` (`changes`, default `changes`) - `amount` (string, required) - `comparison` (one of `same_days_previous_week`, `same_days_previous_month`, `previous_7_days`, required) - `floor` (string, nullable) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - Variant 3: CommitmentParams - `kind` (`commitment`, default `commitment`) - `watch` (one of `expiry`, `lapsed`, `utilization`, `saturation`, `contract_overage`, required) - `cost_basis` (one of `unblended`, `blended`, `amortized`, `net_unblended`, `net_amortized`, default `net_amortized`) - `threshold` (string, default `30`) - `coverage_below` (string, default `80`) - `source_id` (string, required) - `recipients` (array of object, required) - `name` (string, required) - `email` (string, required) - `member` (boolean, required) - `slack_channels` (array of object): The Slack channels this alert posts in, each once per firing - `id` (string, required) - `name` (string, required): The channel's name when the alert was saved, without the # - `slack_channel` (string, nullable): Deprecated and always null: read slack_channels - `enabled` (boolean, required) - `paused_by` (string, nullable) - `sent_to_me` (boolean, default `false`) - `watching` (array of object): The live state of each thing this alert watches. Empty until a sweep has measured it - `key` (string, required): Empty for a rule that watches one thing, the slice for a split rule, the commitment id for a commitment rule - `state` (one of `ok`, `pending`, `firing`, required): pending is a crossing waiting for a second sweep to agree; firing has been announced - `period` (string, nullable): The period the firing was announced for - `observed_usd` (number, nullable): The last figure measured: dollars for a spend rule, days or a percentage for a commitment rule #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Accounts Source: https://docs.levelfour.ai/api-reference/accounts.md - [Start a connect-accounts integration session](/api-reference/accounts/post-accounts-integrations) - [Poll connect-accounts integration status](/api-reference/accounts/get-accounts-integrations-by-id) - [Finalize a GitHub App install via the Setup URL callback](/api-reference/accounts/post-accounts-integrations-by-id-github-complete) - [Finalize a GitLab connection via the OAuth callback](/api-reference/accounts/post-accounts-integrations-by-id-gitlab-complete) - [Finalize a Jira connection via the OAuth callback](/api-reference/accounts/post-accounts-integrations-by-id-jira-complete) - [Finalize a Slack workspace connection via the OAuth callback](/api-reference/accounts/post-accounts-integrations-by-id-slack-complete) - [List active Slack workspace connections for the current tenant](/api-reference/accounts/get-accounts-slack-connections) - [Disconnect a Slack workspace](/api-reference/accounts/delete-accounts-slack-connections-by-id) - [List the Slack channels cost alerts can post to](/api-reference/accounts/get-accounts-slack-channels) - [The Slack channel cost alerts post to](/api-reference/accounts/get-accounts-slack-alerts-channel) - [Choose the Slack channel cost alerts post to, or stop posting them](/api-reference/accounts/put-accounts-slack-alerts-channel) - [List GitHub App installations for the current tenant](/api-reference/accounts/get-accounts-github-installations) - [List active GitLab connections for the current tenant](/api-reference/accounts/get-accounts-gitlab-connections) - [Disconnect a GitLab connection and remove its webhook](/api-reference/accounts/delete-accounts-gitlab-connections-by-id) - [List active Jira connections for the current tenant](/api-reference/accounts/get-accounts-jira-connections) - [Disconnect a Jira connection for the current tenant](/api-reference/accounts/delete-accounts-jira-connections-by-id) - [List connected cloud accounts for the current tenant](/api-reference/accounts/get-accounts) - [List per-account LevelFour capability status for the current tenant](/api-reference/accounts/get-accounts-connections) - [List customer modules for the current tenant](/api-reference/accounts/get-accounts-modules) --- # Disconnect a GitLab connection and remove its webhook Source: https://docs.levelfour.ai/api-reference/accounts/delete-accounts-gitlab-connections-by-id.md ## DELETE /api/v1/accounts/gitlab/connections/{connection_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `connection_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `webhook_removed` (boolean, required): True if the GitLab webhook was deleted, False if it was already gone or could not be reached #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Disconnect a Jira connection for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/delete-accounts-jira-connections-by-id.md ## DELETE /api/v1/accounts/jira/connections/{connection_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `connection_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `disconnected` (boolean, required): True if an active connection was found and disconnected #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Disconnect a Slack workspace Source: https://docs.levelfour.ai/api-reference/accounts/delete-accounts-slack-connections-by-id.md ## DELETE /api/v1/accounts/slack/connections/{connection_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `connection_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `disconnected` (boolean, required): True if an active connection was found and disconnected #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List per-account LevelFour capability status for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-connections.md ## GET /api/v1/accounts/connections ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `account_id` (string, required): 12-digit AWS account identifier - `account_name` (string, nullable) - `status` (one of `pending_role`, `connected`, `error`, `revoked`, required) - `capabilities` (array of object, required) - `key` (string, required): Stable capability identifier (e.g. 'automated_savings') - `name` (string, required): Human-friendly capability name - `ready` (boolean, nullable, required): True when enabled, False when setup is required, null when the status could not be determined - `action` (string, required): enable when setup is required, otherwise none - `launch_stack_url` (string, nullable): One-click CloudFormation quick-create URL, present when setup is required #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List GitHub App installations for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-github-installations.md ## GET /api/v1/accounts/github/installations ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `installation_id` (integer, required): GitHub App installation ID - `account_login` (string, required): GitHub organization or user login the app is installed on - `repos` (array of string): Full names (owner/name) of repos in the installation - `connected_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List active GitLab connections for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-gitlab-connections.md ## GET /api/v1/accounts/gitlab/connections ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `connection_id` (string, required): Opaque identifier for the connection, used to disconnect it - `namespace` (string, required): Connected path: a group full path (scope=group) or a project full path (scope=project) - `scope` (one of `group`, `project`, required): Whether an entire group or a single project is connected - `gitlab_host` (string, required): Base URL of the GitLab instance the connection targets - `connected_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Poll connect-accounts integration status Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-integrations-by-id.md ## GET /api/v1/accounts/integrations/{integration_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `integration_id` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `integration_id` (string, required) - `provider` (one of `aws`, `gcp`, `github`, `gitlab`, `jira`, `slack`, required) - `mode` (one of `single`, `organization`, `github_app`, `gitlab_oauth`, `jira_oauth`, `slack_oauth`, required) - `status` (one of `pending`, `verifying`, `connected`, `failed`, `expired`, required) - `discovered_account_ids` (array of string, nullable): Populated once status='connected'. AWS: one entry per onboarded account. GitHub: one entry per installed GitHub org login. - `error_message` (string, nullable): Populated when status='failed' - `polling_hint_ms` (integer, required): How long the dashboard should wait before polling again; 0 when terminal - `expires_at` (date-time string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List active Jira connections for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-jira-connections.md ## GET /api/v1/accounts/jira/connections ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `connection_id` (string, required): Opaque identifier for the connection, used to disconnect it - `sites` (array of object, required): Sites this connection can reach - `id` (string, required): Atlassian cloud id of the site (used as site_id when creating a task) - `name` (string, required): Human-friendly site name - `url` (string, required): Base url of the site, e.g. https://acme.atlassian.net - `connected_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List customer modules for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-modules.md ## GET /api/v1/accounts/modules ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `module_id` (string, required): Module identifier (e.g. 'github_bot', 'cloud_savings') - `status` (one of `inactive`, `trial`, `active`, `past_due`, `revoked`, `beta`, required): Current module state for this tenant - `plan_tier` (string, nullable): Plan tier label, free text (e.g. 'paid', 'free') - `started_at` (date-time string, nullable) - `expires_at` (date-time string, nullable) - `config` (object, nullable): Module-specific config (e.g. trial caps, allowed recommendation ids, enabled providers) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # The Slack channel cost alerts post to Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-slack-alerts-channel.md ## GET /api/v1/accounts/slack/alerts-channel ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `channel` (object, nullable, required): The channel a new cost alert starts with, or null when there is no default - `id` (string, required) - `name` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List the Slack channels cost alerts can post to Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-slack-channels.md ## GET /api/v1/accounts/slack/channels ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `id` (string, required) - `name` (string, required) - `is_private` (boolean, required) - `truncated` (boolean, required): True when the workspace has more channels than the list reads #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List active Slack workspace connections for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts-slack-connections.md ## GET /api/v1/accounts/slack/connections ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `connection_id` (string, required): Opaque identifier for the connection, used to disconnect it - `team_id` (string, required): Slack workspace id - `team_name` (string, required): Slack workspace name - `granted_scopes` (array of string, required): Scopes the workspace actually granted. The history scopes are optional in the app manifest, so an admin can approve the install without them. - `can_answer_unmentioned` (boolean, required): True when the granted scopes allow reading a thread reply that does not mention the bot. False means the bot still answers an @mention, but cannot pick up a plain reply, and the dashboard should say so. - `connected_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List connected cloud accounts for the current tenant Source: https://docs.levelfour.ai/api-reference/accounts/get-accounts.md ## GET /api/v1/accounts ### Authentication Send `Authorization: Bearer `. ### Parameters - `page` (query, integer, default `1`): Page number (1-indexed) - `page_size` (query, integer, default `50`): Items per page (max 200) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `provider` (one of `aws`, `gcp`, default `aws`): Cloud provider that owns this account row (aws or gcp) - `account_id` (string, required): AWS 12-digit account id, or a GCP project / billing-account id - `account_name` (string, nullable) - `region_name` (string, default `us-east-1`) - `status` (one of `pending_role`, `connected`, `error`, `revoked`, required) - `is_management_account` (boolean, default `false`) - `organization_id` (string, nullable) - `connected_at` (date-time string, nullable) - `cur_status` (one of `not_enabled`, `provisioning`, `active`, `action_needed`, `error`, default `not_enabled`): Detailed cost data (CUR 2.0) ingestion state for this account - `cur_region` (string, nullable): Region the CUR 2.0 export delivers in, once detected - `cur_last_ingested_at` (date-time string, nullable): When LevelFour last ingested CUR data for this account - `cur_last_checked_at` (date-time string, nullable): When a sweep last looked for this account's CUR export, whether or not it found one. Null means no sweep has ever reached it, which is not the same as the customer not having set one up - `cur_last_error` (string, nullable): Last CUR ingestion error for this account, when cur_status is 'error' - `cur_data_through` (date string, nullable): Last usage day the Google Cloud billing export delivered - `cur_last_delivery_at` (date-time string, nullable): When the Google Cloud billing export table last changed - `cud_status` (one of `not_enabled`, `provisioning`, `active`, `action_needed`, `error`, default `not_enabled`): Committed Use Discount (CUD) metadata export ingestion state for this account (GCP) - `cud_last_ingested_at` (date-time string, nullable): When LevelFour last ingested CUD metadata for this account - `cud_last_error` (string, nullable): Last CUD ingestion error for this account, when cud_status is 'error' - `pricing_status` (one of `not_enabled`, `provisioning`, `active`, `action_needed`, `error`, default `not_enabled`): Pricing export ingestion state for this account (GCP) - `pricing_last_ingested_at` (date-time string, nullable): When LevelFour last ingested pricing data for this account - `pricing_last_error` (string, nullable): Last pricing ingestion error for this account, when pricing_status is 'error' - `cur_delivery_state` (one of `current`, `stalled`, `unknown`, required): Whether the Google Cloud billing export is still delivering: stalled once its last usage day is more than GCP_BQ_STALE_AFTER_DAYS old, unknown when nothing has been ingested - `pagination` (object, required): Pagination metadata - `total_items` (integer, required): Total number of items across all pages - `total_pages` (integer, required): Total number of pages - `current_page` (integer, required): Current page number (1-indexed) - `page_size` (integer, required): Number of items per page - `has_next` (boolean, required): Whether there is a next page - `has_previous` (boolean, required): Whether there is a previous page #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Finalize a GitHub App install via the Setup URL callback Source: https://docs.levelfour.ai/api-reference/accounts/post-accounts-integrations-by-id-github-complete.md ## POST /api/v1/accounts/integrations/{integration_id}/github-complete ### Authentication Send `Authorization: Bearer `. ### Parameters - `integration_id` (path, string, required) ### Request body Required. Content type `application/json`. - `installation_id` (integer, required): GitHub App installation ID returned by the Setup URL callback - `setup_action` (one of `install`, `update`, required): GitHub Setup URL action. install on first install, update when repos/permissions changed. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `integration_id` (string, required) - `provider` (one of `aws`, `gcp`, `github`, `gitlab`, `jira`, `slack`, required) - `mode` (one of `single`, `organization`, `github_app`, `gitlab_oauth`, `jira_oauth`, `slack_oauth`, required) - `status` (one of `pending`, `verifying`, `connected`, `failed`, `expired`, required) - `discovered_account_ids` (array of string, nullable): Populated once status='connected'. AWS: one entry per onboarded account. GitHub: one entry per installed GitHub org login. - `error_message` (string, nullable): Populated when status='failed' - `polling_hint_ms` (integer, required): How long the dashboard should wait before polling again; 0 when terminal - `expires_at` (date-time string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Finalize a GitLab connection via the OAuth callback Source: https://docs.levelfour.ai/api-reference/accounts/post-accounts-integrations-by-id-gitlab-complete.md ## POST /api/v1/accounts/integrations/{integration_id}/gitlab-complete ### Authentication Send `Authorization: Bearer `. ### Parameters - `integration_id` (path, string, required) ### Request body Required. Content type `application/json`. - `code` (string, required): OAuth authorization code returned to the GitLab callback - `namespace` (string, nullable): Legacy single-target path. When scope='group', a group full path (e.g. acme or acme/infra). When scope='project', a single project full path (e.g. acme/infra/billing). Provide either namespace (legacy) or projects (multi-project mint-and-store), not both. - `projects` (array of string, nullable): Multi-project path: one or more project full paths (e.g. acme/infra/billing). Requires scope='project'. Each project mints its own scoped Project Access Token at connect time so the bot has a per-project identity and the connector's OAuth token is never stored. - `scope` (one of `group`, `project`, default `group`): Whether to connect an entire group (registers a group webhook, needs GitLab Premium/Ultimate) or one or more projects (registers a project webhook each, works on all tiers). - `gitlab_host` (string, nullable): Base URL of the GitLab instance for self-managed customers. Defaults to gitlab.com. ### Responses #### 200: Multi-project connect result (when projects is supplied). Partial success is a 200: see connected_count / failed_count and per-project results. - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `integration_id` (string, required): The integration this multi-project connect finalized - `results` (array of object, required): One result per requested project, in request order - `project_path` (string, required): The project full path this result is for - `status` (one of `connected`, `needs_permission`, `tier_unsupported`, `not_found`, `error`, required): Per-project outcome. connected: PAT minted and stored. needs_permission: the connector's GitLab role cannot mint a Project Access Token. tier_unsupported: the GitLab plan does not offer Project Access Tokens. not_found: the project path did not resolve. error: an unexpected failure for this project only. - `connection_id` (string, nullable): Stored connection id when status='connected' - `bot_user_id` (integer, nullable): The Project Access Token bot user id when status='connected' - `webhook_id` (integer, nullable): The registered project webhook id when status='connected' - `message` (string, nullable): Human-readable detail for a non-connected status - `connected_count` (integer, required): Number of projects that connected successfully - `failed_count` (integer, required): Number of projects that did not connect #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Finalize a Jira connection via the OAuth callback Source: https://docs.levelfour.ai/api-reference/accounts/post-accounts-integrations-by-id-jira-complete.md ## POST /api/v1/accounts/integrations/{integration_id}/jira-complete ### Authentication Send `Authorization: Bearer `. ### Parameters - `integration_id` (path, string, required) ### Request body Required. Content type `application/json`. - `code` (string, required): OAuth authorization code returned to the Jira callback - `state` (string, required): The state value Atlassian echoed back; must equal the integration id (CSRF check) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `integration_id` (string, required): The integration this OAuth callback finalized - `connected` (boolean, required): True once the tokens were stored and the connection is live - `sites` (array of object, required): Sites the connected Atlassian account can reach - `id` (string, required): Atlassian cloud id of the site (used as site_id when creating a task) - `name` (string, required): Human-friendly site name - `url` (string, required): Base url of the site, e.g. https://acme.atlassian.net #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Finalize a Slack workspace connection via the OAuth callback Source: https://docs.levelfour.ai/api-reference/accounts/post-accounts-integrations-by-id-slack-complete.md ## POST /api/v1/accounts/integrations/{integration_id}/slack-complete ### Authentication Send `Authorization: Bearer `. ### Parameters - `integration_id` (path, string, required) ### Request body Required. Content type `application/json`. - `code` (string, required): OAuth authorization code returned to the Slack callback - `state` (string, required): The state value Slack echoed back; must equal the integration id (CSRF check) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `integration_id` (string, required): The integration this OAuth callback finalized - `connected` (boolean, required): True once the bot token was stored and the connection is live - `connection` (object, required) - `connection_id` (string, required): Opaque identifier for the connection, used to disconnect it - `team_id` (string, required): Slack workspace id - `team_name` (string, required): Slack workspace name - `granted_scopes` (array of string, required): Scopes the workspace actually granted. The history scopes are optional in the app manifest, so an admin can approve the install without them. - `can_answer_unmentioned` (boolean, required): True when the granted scopes allow reading a thread reply that does not mention the bot. False means the bot still answers an @mention, but cannot pick up a plain reply, and the dashboard should say so. - `connected_at` (date-time string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Start a connect-accounts integration session Source: https://docs.levelfour.ai/api-reference/accounts/post-accounts-integrations.md ## POST /api/v1/accounts/integrations ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `provider` (one of `aws`, `gcp`, `github`, `gitlab`, `jira`, `slack`, default `aws`): Cloud provider identifier - `mode` (one of `single`, `organization`, `github_app`, `gitlab_oauth`, `jira_oauth`, `slack_oauth`, required): Onboarding mode. AWS: single|organization. GitHub: github_app. GitLab: gitlab_oauth. Jira: jira_oauth. Slack: slack_oauth. - `target_ou_id` (string, nullable): AWS Organizations root ID (r-...) or organizational unit ID (ou-...-...). Required when provider='aws' and mode='organization'; ignored otherwise. - `gitlab_host` (string, nullable): Base URL of the GitLab instance for self-managed customers, e.g. https://gitlab.acme.com. Optional when provider='gitlab' (defaults to gitlab.com); ignored otherwise. ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `integration_id` (string, required): UUID minted per click; correlates pingback/callback to dashboard session - `launch_stack_url` (string, nullable): AWS Console quickcreate URL with pre-filled CFN parameters. Populated when provider='aws'. - `launch_install_url` (string, nullable): Provider authorization URL with state parameter. GitHub: App install URL. GitLab: OAuth authorize URL. Jira: Atlassian OAuth 2.0 (3LO) authorize URL. Populated when provider is github, gitlab, or jira. - `connect_command` (string, nullable): Shell command the customer pastes into their own Cloud Shell to grant LevelFour read access. Populated when provider='gcp'. - `expires_at` (date-time string, required): When the integration session expires (no pingback/callback received) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Choose the Slack channel cost alerts post to, or stop posting them Source: https://docs.levelfour.ai/api-reference/accounts/put-accounts-slack-alerts-channel.md ## PUT /api/v1/accounts/slack/alerts-channel ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `channel_id` (string, nullable): A public or private channel id, or null for no default ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `channel` (object, nullable, required): The channel a new cost alert starts with, or null when there is no default - `id` (string, required) - `name` (string, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Integrations Source: https://docs.levelfour.ai/api-reference/integrations.md - [Whether the current tenant has the tool connected](/api-reference/integrations/get-integrations-by-id-connection) - [List the Jira sites the connected account can reach](/api-reference/integrations/get-integrations-by-id-sites) - [List the Jira projects a task can be filed under, for a site](/api-reference/integrations/get-integrations-by-id-destinations) - [Live status of a task created for a savings recommendation](/api-reference/integrations/get-integrations-by-id-tasks-by-id-status) - [Create a task in the tool for a savings recommendation](/api-reference/integrations/post-integrations-by-id-tasks) --- # Whether the current tenant has the tool connected Source: https://docs.levelfour.ai/api-reference/integrations/get-integrations-by-id-connection.md ## GET /api/v1/integrations/{tool}/connection ### Authentication Send `Authorization: Bearer `. ### Parameters - `tool` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `tool` (one of `jira`, `trello`, required): The tool this connection status is for - `connected` (boolean, required): True once the tenant has completed the OAuth connect flow - `connected_at` (date-time string, nullable): When the connection was established #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List the Jira projects a task can be filed under, for a site Source: https://docs.levelfour.ai/api-reference/integrations/get-integrations-by-id-destinations.md ## GET /api/v1/integrations/{tool}/destinations ### Authentication Send `Authorization: Bearer `. ### Parameters - `tool` (path, string, required) - `site_id` (query, string, required): Atlassian cloud id of the site to list projects for ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `id` (string, required): Jira project id the task will be filed under - `name` (string, required): Human-friendly project name shown in the chooser #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List the Jira sites the connected account can reach Source: https://docs.levelfour.ai/api-reference/integrations/get-integrations-by-id-sites.md ## GET /api/v1/integrations/{tool}/sites ### Authentication Send `Authorization: Bearer `. ### Parameters - `tool` (path, string, required) ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `items` (array of object, required) - `id` (string, required): Atlassian cloud id, passed back as site_id when listing destinations or creating a task - `name` (string, required): Human-friendly site name shown in the chooser - `url` (string, required): Base url of the site, e.g. https://acme.atlassian.net #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Live status of a task created for a savings recommendation Source: https://docs.levelfour.ai/api-reference/integrations/get-integrations-by-id-tasks-by-id-status.md ## GET /api/v1/integrations/{tool}/tasks/{issue_key}/status ### Authentication Send `Authorization: Bearer `. ### Parameters - `tool` (path, string, required) - `issue_key` (path, string, required) - `site_id` (query, string, required): Atlassian cloud id of the site the issue lives on ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `name` (string, required): The issue's current status name, e.g. To Do, In Progress, Done - `category` (string, required): The status category key: new, indeterminate, done, or undefined when unknown #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Create a task in the tool for a savings recommendation Source: https://docs.levelfour.ai/api-reference/integrations/post-integrations-by-id-tasks.md ## POST /api/v1/integrations/{tool}/tasks ### Authentication Send `Authorization: Bearer `. ### Parameters - `tool` (path, string, required) ### Request body Required. Content type `application/json`. - `recommendation_id` (string, nullable): The savings recommendation this task tracks - `commitment_id` (string, nullable): The commitment this task tracks - `title` (string, required): Task title, prefilled from the record and user-editable - `description` (string, default ``): Task description (may be empty) - `link` (string, default ``): Dashboard deep link back to the record - `monthly_savings` (number, default `0`): Savings only. Estimated USD savings per month - `annual_savings` (number, default `0`): Savings only. Estimated USD savings per year - `committed_monthly` (number, nullable): Commitment only. What the commitment costs per month - `currency` (string, nullable): Commitment only. Currency of committed_monthly - `term_months` (integer, nullable): Commitment only. Length of the term in months - `ends_at` (date string, nullable): Commitment only. The day the commitment ends - `site_id` (string, nullable): Atlassian cloud id of the site to create the issue on (required for Jira) - `destination_id` (string, nullable): Jira project id to file the issue under (required for Jira) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required): Identifier of the created task in the tool (Jira issue key, e.g. PLAT-123) - `url` (string, required): Absolute url to the created task - `title` (string, required): Echo of the created task title #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Webhooks Source: https://docs.levelfour.ai/api-reference/webhooks.md - [List webhook endpoints](/api-reference/webhooks/get-webhooks-endpoints) - [Register a webhook endpoint](/api-reference/webhooks/post-webhooks-endpoints) - [Delete a webhook endpoint](/api-reference/webhooks/delete-webhooks-endpoints-by-id) --- # Delete a webhook endpoint Source: https://docs.levelfour.ai/api-reference/webhooks/delete-webhooks-endpoints-by-id.md ## DELETE /api/v1/webhooks/endpoints/{endpoint_id} ### Authentication Send `Authorization: Bearer `. ### Parameters - `endpoint_id` (path, string, required): Webhook endpoint ID ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `deleted` (boolean, required) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # List webhook endpoints Source: https://docs.levelfour.ai/api-reference/webhooks/get-webhooks-endpoints.md ## GET /api/v1/webhooks/endpoints ### Authentication Send `Authorization: Bearer `. ### Responses #### 200: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (array of object, required) - `id` (string, required) - `url` (string, required) - `event_types` (array of string, required) - `description` (string, nullable) - `created_at` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Register a webhook endpoint Source: https://docs.levelfour.ai/api-reference/webhooks/post-webhooks-endpoints.md ## POST /api/v1/webhooks/endpoints ### Authentication Send `Authorization: Bearer `. ### Request body Required. Content type `application/json`. - `url` (string, required): Webhook endpoint URL - `event_types` (array of string, required): Event types to subscribe to - `description` (string, nullable) ### Responses #### 201: Successful Response - `success` (boolean, default `true`) - `timestamp` (date-time string) - `data` (object, required) - `id` (string, required) - `url` (string, required) - `event_types` (array of string, required) - `description` (string, nullable) - `created_at` (string, nullable) #### Errors - `400`: Bad Request - `401`: Authentication Error - `403`: Authorization Error - `404`: Not Found - `409`: Conflict - `422`: Validation Error - `429`: Rate Limit Exceeded - `500`: Internal Server Error Body of `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`: - `success` (`false`, required) - `error` (object, required) - `code` (string, required) - `message` (string, required) - `details` (any) - `timestamp` (date-time string, required) --- # Resources Source: https://docs.levelfour.ai/resources.md Each page below covers one resource: the methods that read or change it, the parameters they take, and the call in each SDK that carries it, with plain HTTP where an SDK does not. ## The resources | Resource | What it covers | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [Recommendations](/resources/recommendations) | Pending savings opportunities: idle resources, rightsizing, reserved instances | | [Savings](/resources/savings) | Realized savings from implemented recommendations | | [Costs](/resources/costs) | Current cloud costs with breakdowns by service, account, and time period | | [Providers](/resources/providers) | Provider-scoped reads for recommendations, savings and costs on AWS, GCP, Azure and Kubernetes | | [Cost Alerts](/resources/cost-alerts) | Alerts that watch an account, a saved cost view or your commitments: create, change, pause, delete, and what each one said | | [Commitments](/resources/commitments) | Reserved Instances and Savings Plans: inventory, utilization, coverage, effective savings rate and renewal pricing | | [Google Cloud](/resources/google-cloud) | Google Cloud reads beyond the bill: data availability, inventory, idle resources, labels, utilization, budgets and access | | [Accounts](/resources/accounts) | Connected cloud accounts, what LevelFour can do in each, your modules, and connected work tools | > **Warning** > > Recommendations and Savings are not two views of one number: one counts opportunity nobody has applied, the other counts what was applied and measured. A figure that adds both double counts. ## Next * [SDKs](/sdks) is the client these methods hang off: constructor options, minimum versions and the sub-client names * [Pagination](/pagination) has the envelope every list response carries, and the iterators that walk it for you * [Error handling](/error-handling) has which status raises which class in each SDK, and what the SDKs retry before you see it * [Webhooks](/webhooks) is registering an endpoint and verifying the signature before you act on the payload * [API reference](/api-reference) is the generated REST reference for the endpoints behind every method here --- # Providers Source: https://docs.levelfour.ai/resources/providers.md These methods read recommendations, savings and costs for one cloud at a time. The provider IDs are `aws`, `gcp`, `azure` and `k8s`, and every method except [List providers](#list-providers) takes one. > **Note** > > Only [List providers](#list-providers) lives on `client.providers`. That namespace has exactly one method. Everything else on this page is grouped by what it reads, not by where it lives, so the calls sit on `client.recommendations` and `client.costs` and take a provider ID as their first argument. A provider carries data only once you have [connected it](/quickstart). > **Error** > > A provider your organization has not connected returns `404` on every provider-scoped path below. Call [List providers](#list-providers) first rather than assuming an ID is available. ## List providers Returns all configured cloud providers for your account. **Python** ```python providers = client.providers.list() ``` **TypeScript** ```typescript const providers = await client.providers.list(); ``` **Go** ```go providers, err := client.Providers.List(ctx) ``` ## Recommendations ### Get top recommendations Returns the top 7 recommendations for a specific provider, sorted by savings. **Python** ```python top = client.recommendations.get_top("aws") ``` **TypeScript** ```typescript const top = await client.recommendations.getTop({ provider_id: "aws" }); ``` **Go** ```go top, err := client.Recommendations.GetTop(ctx, "aws") ``` ### List recommendations Returns a paginated, filterable list of recommendations for a provider. **Python** ```python page = client.recommendations.list_by_provider( "aws", page=1, page_size=50, sort_by="monthly_savings", sort_order="desc", service=["EC2", "RDS"], environment=["production"], display_status=["available", "pending"], ) for rec in page: print(f"{rec.recommendation_id}: ${rec.monthly_savings}/mo") ``` **TypeScript** ```typescript for await (const rec of await client.recommendations.listByProvider({ provider_id: "aws", page_size: 50, sort_by: "monthly_savings", sort_order: "desc", service: ["EC2", "RDS"], display_status: ["available", "pending"], })) { console.log(`${rec.recommendation_id}: $${rec.monthly_savings}/mo`); } ``` **Go** ```go page, err := client.Recommendations.ListByProvider(ctx, "aws", &levelfour.ListByProviderRecommendationsRequest{ PageSize: levelfour.Int(50), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListByProviderRecommendationsRequestSortOrder("desc").Ptr(), Service: []string{"EC2", "RDS"}, DisplayStatus: []string{"available", "pending"}, }, ) ``` #### Parameters | Parameter | Type | Description | | ------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | | `provider_id` | `string` | Provider: `aws`, `gcp`, `azure`, `k8s` | | `start` | `string` | Start date (ISO 8601: `YYYY-MM-DD`) | | `end` | `string` | End date (ISO 8601: `YYYY-MM-DD`) | | `page` | `int` | Page number (1-indexed) | | `page_size` | `int` | Items per page | | `sort_by` | `string` | Sort field: `recommendation_id`, `service`, `environment`, `account`, `tag`, `monthly_savings`, `savings_percentage`, `status` | | `sort_order` | `string` | `asc` or `desc` | | `service` | `string[]` | Filter by service(s) | | `environment` | `string[]` | Filter by environment(s) | | `account` | `string[]` | Filter by account(s) | | `tag` | `string[]` | Filter by tag(s) | | `virtual_tag_key` | `string` | A virtual tag's name or id. Keeps the opportunities whose resources carry one of `virtual_tag_value` under it | | `virtual_tag_value` | `string[]` | Values of `virtual_tag_key` to keep. `__unallocated__` keeps the opportunities no config assigned | | `display_status` | `string[]` | Filter by status: `available`, `pending`, `processing`, `optimized`, `rejected`, `unavailable` | [Pagination](/pagination) has the defaults these parameters fall back to, the page-size cap, and the auto-paginating iterators. ### Get recommendations overview Returns overview metrics for a provider: total spend, available savings, pending savings, and saved-to-date. **Python** ```python overview = client.recommendations.get_provider_overview("aws") ``` **TypeScript** ```typescript const overview = await client.recommendations.getProviderOverview({ provider_id: "aws", }); ``` **Go** ```go overview, err := client.Recommendations.GetProviderOverview(ctx, "aws") ``` ### Get recommendation filters Returns the distinct values available for filtering: environments, accounts, tags, and the virtual tag keys and values your own rules define. **Python** ```python filters = client.recommendations.get_provider_filters("aws") ``` **TypeScript** ```typescript const filters = await client.recommendations.getProviderFilters({ provider_id: "aws", }); ``` **Go** ```go filters, err := client.Recommendations.GetProviderFilters(ctx, "aws") ``` ## Realized savings ### List realized savings Returns a paginated, filterable list of realized savings for a provider. **Python** ```python page = client.recommendations.audit.list_by_provider( "aws", page=1, page_size=50, preset="6M", service=["EC2"], ) ``` **TypeScript** ```typescript const page = await client.recommendations.audit.listByProvider({ provider_id: "aws", page_size: 50, preset: "6M", service: ["EC2"], }); ``` **Go** ```go page, err := client.Recommendations.Audit.ListByProvider(ctx, "aws", &levelfour.ListByProviderAuditRequest{ PageSize: levelfour.Int(50), }, ) ``` ### Get realized savings summary Returns aggregate savings totals for a provider. **Python** ```python summary = client.recommendations.audit.get_provider_summary("aws") ``` **TypeScript** ```typescript const summary = await client.recommendations.audit.getProviderSummary({ provider_id: "aws", }); ``` **Go** ```go summary, err := client.Recommendations.Audit.GetProviderSummary(ctx, "aws") ``` ## Costs ### Get costs summary Returns a costs summary for a specific provider. **Python** ```python costs = client.costs.get_provider_summary("aws") ``` **TypeScript** ```typescript const costs = await client.costs.getProviderSummary({ provider_id: "aws" }); ``` **Go** ```go costs, err := client.Costs.GetProviderSummary(ctx, "aws", &levelfour.GetProviderSummaryCostsRequest{}) ``` ### Get costs filters Returns distinct services, regions, and accounts available for filtering within a date range. **Python** ```python filters = client.costs.get_provider_filters( "aws", start="2025-01-01T00:00:00.000Z", end="2025-03-31T00:00:00.000Z", ) ``` **TypeScript** ```typescript const filters = await client.costs.getProviderFilters({ provider_id: "aws", start: "2025-01-01T00:00:00.000Z", end: "2025-03-31T00:00:00.000Z", }); ``` **Go** ```go filters, err := client.Costs.GetProviderFilters(ctx, "aws", &levelfour.GetProviderFiltersCostsRequest{ Start: levelfour.String("2025-01-01T00:00:00.000Z"), End: levelfour.String("2025-03-31T00:00:00.000Z"), }, ) ``` ### List costs Returns a cost breakdown for a provider in multiple formats: `table`, `chart`, `csv`, `raw`. **Python** ```python costs = client.costs.list_by_provider( "aws", format="table", page=1, page_size=50, sort_by="cost", sort_order="desc", ) ``` **TypeScript** ```typescript const costs = await client.costs.listByProvider({ provider_id: "aws", format: "table", page: 1, page_size: 50, sort_by: "cost", sort_order: "desc", }); ``` **Go** ```go costs, err := client.Costs.ListByProvider(ctx, "aws", &levelfour.ListByProviderCostsRequest{ Format: levelfour.String("table"), Page: levelfour.Int(1), PageSize: levelfour.Int(50), SortBy: levelfour.String("cost"), SortOrder: levelfour.ListByProviderCostsRequestSortOrder("desc").Ptr(), }, ) ``` ### Get costs timeline Returns a costs timeline for a provider over a date range. **Python** ```python timeline = client.costs.get_provider_timeline( "aws", start="2025-01-01T00:00:00.000Z", end="2025-03-31T00:00:00.000Z", ) ``` **TypeScript** ```typescript const timeline = await client.costs.getProviderTimeline({ provider_id: "aws", start: "2025-01-01T00:00:00.000Z", end: "2025-03-31T00:00:00.000Z", }); ``` **Go** ```go timeline, err := client.Costs.GetProviderTimeline(ctx, "aws", &levelfour.GetProviderTimelineCostsRequest{ Start: levelfour.String("2025-01-01T00:00:00.000Z"), End: levelfour.String("2025-03-31T00:00:00.000Z"), }, ) ``` ## Potential savings ### Get potential savings summary Returns potential savings summary for a provider. **Python** ```python potential = client.recommendations.get_provider_potential_savings_summary("aws") ``` **TypeScript** ```typescript const potential = await client.recommendations.getProviderPotentialSavingsSummary({ provider_id: "aws", }); ``` **Go** ```go potential, err := client.Recommendations.GetProviderPotentialSavingsSummary(ctx, "aws") ``` ### List potential savings Returns a paginated potential savings breakdown for a provider. **Python** ```python page = client.recommendations.list_provider_potential_savings( "aws", page=1, page_size=50, ) ``` **TypeScript** ```typescript const page = await client.recommendations.listProviderPotentialSavings({ provider_id: "aws", page: 1, page_size: 50, }); ``` **Go** ```go page, err := client.Recommendations.ListProviderPotentialSavings(ctx, "aws", &levelfour.ListProviderPotentialSavingsRecommendationsRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), }, ) ``` ## Next * [Recommendations](/resources/recommendations) has the same reads under the `recommendations` namespace, plus the ones that span every provider * [Costs](/resources/costs) does the same for cost breakdowns, and carries the full grouping and tag filter table * [Savings](/resources/savings) is realized savings, including the `SavedItem` response shape * [Filtering and sorting](/guides/filtering-and-sorting) turns these parameters into recipes you can run * [`/api-reference/providers`](/api-reference/providers) is the generated REST reference for every endpoint here --- # Costs Source: https://docs.levelfour.ai/resources/costs.md The costs methods read what your organization spends: an overall summary, per-day and per-month series, and a per-service breakdown you can page, sort, filter and export as CSV. Others rank what is growing, forecast the month, price each usage type per unit, and measure how much spend your tags cover. Some cover the whole organization. The rest take a provider ID and answer the same questions for one cloud. ## Organization-wide ### Get cost summary Returns an overall cost summary: total costs, forecast, savings, and a breakdown by provider. **Python** ```python summary = client.costs.get_summary() ``` **TypeScript** ```typescript const summary = await client.costs.getSummary(); ``` **Go** ```go summary, err := client.Costs.GetSummary(ctx, &levelfour.GetSummaryCostsRequest{}) ``` ### List costs Returns a cost breakdown in one of these formats: `table` (paginated), `chart` (aggregated for stacked bar charts), `csv` (export), or `raw` (all records). Format scopes some of the parameters below: `page` and `page_size` apply to `table`, `granularity` to `chart`. **Python** ```python breakdown = client.costs.list( format="table", period="2026-03", provider_id="aws", page=1, page_size=50, sort_by="cost", sort_order="desc", ) ``` **TypeScript** ```typescript const breakdown = await client.costs.list({ format: "table", period: "2026-03", provider_id: "aws", page: 1, page_size: 50, sort_by: "cost", sort_order: "desc", }); ``` **Go** ```go breakdown, err := client.Costs.List(ctx, &levelfour.ListCostsRequest{ Format: levelfour.String("table"), Period: levelfour.String("2026-03"), ProviderID: levelfour.String("aws"), Page: levelfour.Int(1), PageSize: levelfour.Int(50), SortBy: levelfour.String("cost"), SortOrder: levelfour.ListCostsRequestSortOrder("desc").Ptr(), }) ``` #### List costs parameters | Parameter | Type | Description | | ------------- | -------- | -------------------------------------------------------------------------- | | `format` | `string` | Response format: `table`, `chart`, `csv`, `raw` | | `period` | `string` | Period in `YYYY-MM` format (defaults to current month) | | `provider_id` | `string` | Filter by provider: `aws`, `gcp`, `azure`, `k8s` | | `granularity` | `string` | `daily` or `monthly` (for chart format) | | `group_by` | `string` | Grouping dimension: `provider` or `account_id` | | `start` | `string` | Start date (ISO 8601) | | `end` | `string` | End date (ISO 8601) | | `page` | `int` | Page number (table format only) | | `page_size` | `int` | Items per page (table format only) | | `sort_by` | `string` | Sort field: `cost`, `service`, `region`, `account_id`, `change_percentage` | | `sort_order` | `string` | `asc` or `desc` | > **Warning** > > `period` defaults to the current month, and the current month is still running. Pass a completed `YYYY-MM` when the number has to hold still. ### Get daily costs Returns costs aggregated per day for a given date range. **Python** ```python daily = client.costs.get_daily_costs( start="2026-03-01T00:00:00.000Z", end="2026-03-31T00:00:00.000Z", ) ``` **TypeScript** ```typescript const daily = await client.costs.getDailyCosts({ start: "2026-03-01", end: "2026-03-31", }); ``` **Go** ```go daily, err := client.Costs.GetDailyCosts(ctx, &levelfour.GetDailyCostsCostsRequest{ Start: levelfour.String("2026-03-01T00:00:00.000Z"), End: levelfour.String("2026-03-31T00:00:00.000Z"), }) ``` ### Get monthly costs Returns costs aggregated per month across all time. **Python** ```python monthly = client.costs.get_monthly_costs() ``` **TypeScript** ```typescript const monthly = await client.costs.getMonthlyCosts(); ``` **Go** ```go monthly, err := client.Costs.GetMonthlyCosts(ctx) ``` ### Get top growing services Returns the services whose spend grew the most, by percentage, between two windows. `previous_period` compares the last 30 days with the 30 before them. `previous_month` compares this month so far with the whole of last month. **Go** ```go growing, err := client.Costs.GetTopGrowing(ctx, &levelfour.GetTopGrowingAPIV1CostsTopGrowingGetRequest{ Provider: levelfour.String("aws"), CompareTo: levelfour.String("previous_month"), Limit: levelfour.Int(5), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/top-growing?provider=aws&compare_to=previous_month&limit=5" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` | Parameter | Type | Description | | ------------ | -------- | -------------------------------------------------------------------------------- | | `provider` | `string` | Provider ID (defaults to `aws`) | | `compare_to` | `string` | `previous_period` (default) or `previous_month` | | `limit` | `int` | Services to return, 1 to 100 (defaults to 10) | | `credits` | `string` | `after` (default) or `before`. See [Google Cloud credits](#google-cloud-credits) | ### Get a spend forecast Returns the projected spend to the end of the current month, with lower and upper bounds, and a 60-point series: the last 30 days of actual spend followed by the next 30 days of forecast. **Go** ```go forecast, err := client.Costs.GetForecast(ctx, &levelfour.GetForecastAPIV1CostsForecastGetRequest{ Provider: levelfour.String("all"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/forecast?provider=all" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `provider` takes a provider ID or `all`, and defaults to `aws`. A provider with no costs returns an empty forecast rather than an error. ### Get usage and unit costs Returns spend, billable quantity, unit and cost per unit for each usage type. Use it to track a unit price over time, not only the total. **Go** ```go usage, err := client.Costs.GetUsage(ctx, &levelfour.GetUsageAPIV1CostsUsageGetRequest{ Provider: levelfour.String("aws"), Start: levelfour.String("2026-08-01"), End: levelfour.String("2026-08-31"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/usage?provider=aws&start=2026-08-01&end=2026-08-31" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` | Parameter | Type | Description | | -------------- | ---------- | ---------------------------------------------------------------------------------------------------- | | `provider` | `string` | Provider ID (defaults to `aws`) | | `start`, `end` | `string` | Date range | | `service` | `string[]` | Google Cloud only: limit to these services | | `family` | `string` | Google Cloud only: one SKU family, `bigquery`, `network`, `compute` or `other`, returned by category | | `credits` | `string` | `after` (default) or `before` | ### Cost by tag Two reads answer "who spends what" from your tags. The first lists the keys you can group by: the tag keys in your bill plus every [virtual tag](/guides/virtual-tags) key. A bill key that a virtual tag of the same name replaces is left out. **Go** ```go keys, err := client.Costs.GetTagKeys(ctx, &levelfour.GetTagKeysAPIV1CostsByTagKeysGetRequest{ Provider: levelfour.String("aws"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/by-tag/keys?provider=aws" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` The second groups spend by the values of one key, such as `team` or `cost-center`, and splits each value's spend by category. **Go** ```go byTeam, err := client.Costs.GetCostByTag(ctx, &levelfour.GetCostByTagAPIV1CostsByTagGetRequest{ Provider: levelfour.String("aws"), TagKey: levelfour.String("team"), Start: levelfour.String("2026-08-01"), End: levelfour.String("2026-08-31"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/by-tag?provider=aws&tag_key=team&start=2026-08-01&end=2026-08-31" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `tag_key` defaults to `team`. Both reads take `provider`, and the grouped one also takes `start`, `end` and `credits`. ### Get allocation coverage Returns how much of your spend carries a tag key: total, tagged and untagged spend, the coverage percentage, and spend per value. Track it to see whether a tagging policy is landing. **Go** ```go coverage, err := client.Costs.GetAllocation(ctx, &levelfour.GetAllocationAPIV1CostsAllocationGetRequest{ Provider: levelfour.String("aws"), TagKey: levelfour.String("team"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/allocation?provider=aws&tag_key=team" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` | Parameter | Type | Description | | ------------------------ | -------- | ---------------------------------------------------------------------------------- | | `provider` | `string` | Provider ID (defaults to `aws`) | | `tag_key` | `string` | The key to measure. Google Cloud may leave it out to measure coverage by any label | | `start`, `end` | `string` | Date range | | `exclude_system` | `bool` | Google Cloud only: ignore `goog-` and `k8s-` labels (defaults to `true`) | | `include_project_labels` | `bool` | Google Cloud only: count labels set on the project (defaults to `true`) | | `credits` | `string` | `after` (default) or `before` | ### Google Cloud cost by labels Returns Google Cloud spend grouped by up to three label keys at once, in the order you pass them, for example cluster, then namespace, then workload. A label set on a resource wins over the same key set on its project. **Go** ```go tree, err := client.Costs.GetCostByLabels(ctx, &levelfour.GetCostByLabelsAPIV1CostsByLabelsGetRequest{ Provider: levelfour.String("gcp"), Key: []*string{levelfour.String("cluster"), levelfour.String("namespace")}, Filter: []string{"env=production"}, }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/costs/by-labels?provider=gcp&key=cluster&key=namespace&filter=env%3Dproduction" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `key` is required and repeats once per level. `filter` takes `key=value` pairs to narrow the result, and `limit` caps the rows (defaults to 50). ## One provider Each of these takes a provider ID. [Providers](/resources/providers) has the IDs, and what a provider-scoped call returns for one your organization has not connected. ### Get provider summary Returns a per-provider cost summary with monthly costs, forecast, potential savings, and the top 3 services for that provider. **Python** ```python summary = client.costs.get_provider_summary("aws") ``` **TypeScript** ```typescript const summary = await client.costs.getProviderSummary("aws"); ``` **Go** ```go summary, err := client.Costs.GetProviderSummary(ctx, "aws", &levelfour.GetProviderSummaryCostsRequest{}) ``` ### List by provider Returns a paginated per-provider cost breakdown. Every row carries its previous-period cost and change percentage, and the response carries per-date totals across all pages, not just the page you asked for. **Python** ```python breakdown = client.costs.list_by_provider( "aws", preset="30D", granularity="daily", group_by=["service", "region"], service=["EC2", "RDS"], region=["us-east-1"], page=1, page_size=20, sort_by="cost", sort_order="desc", ) ``` **TypeScript** ```typescript const breakdown = await client.costs.listByProvider("aws", { preset: "30D", granularity: "daily", group_by: ["service", "region"], service: ["EC2", "RDS"], region: ["us-east-1"], page: 1, page_size: 20, sort_by: "cost", sort_order: "desc", }); ``` **Go** ```go breakdown, err := client.Costs.ListByProvider(ctx, "aws", &levelfour.ListByProviderCostsRequest{ Preset: levelfour.String("30D"), Granularity: levelfour.String("daily"), GroupBy: []string{"service", "region"}, Service: []string{"EC2", "RDS"}, Region: []string{"us-east-1"}, Page: levelfour.Int(1), PageSize: levelfour.Int(20), SortBy: levelfour.String("cost"), SortOrder: levelfour.ListByProviderCostsRequestSortOrder("desc").Ptr(), }) ``` #### List by provider parameters | Parameter | Type | Description | | ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------- | | `format` | `string` | `table`, `chart`, `csv`, or `raw` (defaults to `table`) | | `start` | `string` | Start date (ISO 8601) | | `end` | `string` | End date (ISO 8601) | | `preset` | `string` | Date preset: `30D`, `6M`, or `12M` (overrides `start`/`end`) | | `granularity` | `string` | `daily` or `monthly` | | `page` | `int` | Page number (1-indexed) | | `page_size` | `int` | Items per page | | `sort_by` | `string` | Sort field: `service`, `environment`, `region`, `account_id`, `cost`, `previous_cost`, `change_percentage` | | `sort_by_date` | `string` | Sort by a specific date column (`YYYY-MM-DD` or `YYYY-MM`, overrides `sort_by`) | | `sort_order` | `string` | `asc` or `desc` | | `group_by` | `string[]` | Multi-dimension grouping: `service`, `account_id`, `region`, `tag`, `virtual_tag` | | `service` | `string[]` | Filter by service | | `environment` | `string[]` | Filter by environment | | `account_id` | `string[]` | Filter by account ID | | `region` | `string[]` | Filter by region | | `tag_key` | `string[]` | Filter by tag key (required when `group_by` includes `tag`) | | `tag_value` | `string[]` | Filter by tag value | | `virtual_tag_key` | `string` | A virtual tag's name or id (required when `group_by` includes `virtual_tag`). One key per request | | `virtual_tag_value` | `string[]` | Filter by value of `virtual_tag_key`. `__unallocated__` matches the cost no config assigned | | `virtual_tag_value_mode` | `string` | `include` or `exclude` | `tag_key` and `tag_value` read tags as your bill carries them. `virtual_tag_key` reads a key LevelFour assigns from your own rules, and [Virtual tags](/guides/virtual-tags) covers how those rules work. Beside a `virtual_tag_key`, grouping is limited to `virtual_tag`, `service`, `region`, `account_id` and `charge_type`, and filtering to `service`, `account_id`, `region`, `charge_type` and `payer_account`. Anything else is a 422. > **Warning** > > `preset` overrides `start` and `end`. Send all three and the window you get is the preset's, not your dates. [Filtering and sorting](/guides/filtering-and-sorting) turns these parameters into ready-to-run recipes, mostly for the `l4` CLI, with the SDK equivalents at the end. ### Get provider filters Returns distinct values available for filtering the per-provider cost breakdown (services, regions, accounts, tag keys, tag values, and the `virtual_tag_keys` and `virtual_tag_values` your own rules define) within a date range. Populate an autocomplete with them, or check a user-supplied filter value before you query with it. **Python** ```python filters = client.costs.get_provider_filters( "aws", start="2026-01-01T00:00:00.000Z", end="2026-01-31T00:00:00.000Z", ) ``` **TypeScript** ```typescript const filters = await client.costs.getProviderFilters("aws", { start: "2026-01-01T00:00:00.000Z", end: "2026-01-31T00:00:00.000Z", }); ``` **Go** ```go filters, err := client.Costs.GetProviderFilters(ctx, "aws", &levelfour.GetProviderFiltersCostsRequest{ Start: levelfour.String("2026-01-01T00:00:00.000Z"), End: levelfour.String("2026-01-31T00:00:00.000Z"), }) ``` ### Get provider timeline Returns a per-provider cost timeline at the requested granularity. Plot it as a trend chart of one provider's cost over time. **Python** ```python timeline = client.costs.get_provider_timeline( "aws", start="2026-01-01T00:00:00.000Z", end="2026-03-31T00:00:00.000Z", granularity="daily", ) ``` **TypeScript** ```typescript const timeline = await client.costs.getProviderTimeline("aws", { start: "2026-01-01T00:00:00.000Z", end: "2026-03-31T00:00:00.000Z", granularity: "daily", }); ``` **Go** ```go timeline, err := client.Costs.GetProviderTimeline(ctx, "aws", &levelfour.GetProviderTimelineCostsRequest{ Start: levelfour.String("2026-01-01T00:00:00.000Z"), End: levelfour.String("2026-03-31T00:00:00.000Z"), Granularity: levelfour.String("daily"), }) ``` ### Get invoice months Returns spend per invoice month, newest first, split by cost type: regular usage, tax, adjustments and rounding. The months match the invoices Google Cloud sends you. Google Cloud only; any other provider returns no months. **Go** ```go invoices, err := client.Providers.GetProviderInvoices(ctx, "gcp", &levelfour.GetProviderInvoicesAPIV1ProvidersProviderIDCostsInvoicesGetRequest{ Months: levelfour.Int(12), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/costs/invoices?months=12" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `months` defaults to 6. ## Google Cloud credits A Google Cloud bill can carry large credits: promotions, free tier, committed use and sustained use discounts. The cost endpoints take a `credits` query parameter to pick which figure you read: | Value | Returns | | ----------------- | ------------------------------- | | `after` (default) | What you pay once credits apply | | `before` | Gross cost, before any credit | Every response that takes `credits` echoes the value it used. AWS figures stay the same under either value. The dashboard's **Before credits / After credits** toggle sets the same parameter, and the per-provider summary for `gcp` adds a `credits_summary` with gross cost, credits and net cost for this month and last. ## Example: export costs to CSV **Python** ```python from levelfour import LevelFour client = LevelFour() csv_data = client.costs.list( format="csv", start="2026-01-01T00:00:00.000Z", end="2026-03-31T00:00:00.000Z", ) print(csv_data) ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); const csvData = await client.costs.list({ format: "csv", start: "2026-01-01T00:00:00.000Z", end: "2026-03-31T00:00:00.000Z", }); console.log(csvData); ``` **Go** ```go csvData, err := client.Costs.List(ctx, &levelfour.ListCostsRequest{ Format: levelfour.String("csv"), Start: levelfour.String("2026-01-01T00:00:00.000Z"), End: levelfour.String("2026-03-31T00:00:00.000Z"), }) ``` ## Next * [Providers](/resources/providers) has the provider IDs and the rest of the provider-scoped surface * [Pagination](/pagination) has the defaults behind `page` and `page_size`, and the iterators that page for you * [API reference](/api-reference/costs) is generated from the schema: every parameter, enum and response field --- # Recommendations Source: https://docs.levelfour.ai/resources/recommendations.md A recommendation is one pending savings opportunity LevelFour found in your cloud accounts. Each one carries the affected service, the estimated monthly and annual savings, and the actions available on it: accept, reject, execute. [Pagination](/pagination) has the defaults, the page-size cap, and the auto-paginating iterators the examples below rely on. ## Methods | Method | Returns | | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | [Get savings by provider](#get-savings-by-provider) | A summary of potential savings grouped by cloud provider | | [Get potential savings](#get-potential-savings) | A potential savings summary grouped by provider | | [Get overview](#get-overview) | Total spend, available savings, pending savings and saved-to-date, across every provider | | [List recommendations](#list-recommendations) | A paginated list of every recommendation. Sorts, does not filter | | [Get recommendation](#get-recommendation) | The full detail for one recommendation | | [Get provider overview](#get-provider-overview) | The same metrics as Get overview, for one provider | | [List by provider](#list-by-provider) | A paginated list for one provider, with filters | | [List in-progress](#list-in-progress) | Every recommendation in `processing` status | | [Choose the resources](#choose-the-resources) | Limits a recommendation to the resources you keep | | [Say why it was rejected](#say-why-it-was-rejected) | Adds a reason to a rejected recommendation | | [Read the activity timeline](#read-the-activity-timeline) | Views and decisions on one recommendation, in order | | [Request execution](#request-execution) | Submits a recommendation for an admin to release | | [List what is waiting for approval](#list-what-is-waiting-for-approval) | Submissions still waiting for an admin | | [Check Automated Savings access](#check-automated-savings-access) | Whether Automated Savings can run for this recommendation | ### Get savings by provider Returns a summary of potential savings grouped by cloud provider (AWS, GCP, Azure, K8s). **Python** ```python summary = client.recommendations.get_savings_by_provider() ``` **TypeScript** ```typescript const summary = await client.recommendations.getSavingsByProvider(); ``` **Go** ```go summary, err := client.Recommendations.GetSavingsByProvider(ctx) ``` ### Get potential savings Returns a potential savings summary grouped by provider. **Python** ```python potential = client.recommendations.get_potential_savings() ``` **TypeScript** ```typescript const potential = await client.recommendations.getPotentialSavings(); ``` **Go** ```go potential, err := client.Recommendations.GetPotentialSavings(ctx) ``` ### Get overview Returns overview metrics across all providers: total spend, available savings, pending savings, and saved-to-date. **Python** ```python overview = client.recommendations.get_overview() ``` **TypeScript** ```typescript const overview = await client.recommendations.getOverview(); ``` **Go** ```go overview, err := client.Recommendations.GetOverview(ctx) ``` ### List recommendations Returns a paginated list of every recommendation. **Python** ```python page = client.recommendations.list( page=1, page_size=50, sort_by="monthly_savings", sort_order="desc", ) for rec in page: print(f"{rec.service}: ${rec.monthly_savings}/mo") ``` **TypeScript** ```typescript for await (const rec of await client.recommendations.list({ page_size: 50, sort_by: "monthly_savings", sort_order: "desc", })) { console.log(`${rec.service}: $${rec.monthly_savings}/mo`); } ``` **Go** ```go page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ PageSize: levelfour.Int(50), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListRecommendationsRequestSortOrder("desc").Ptr(), }) iter := page.Iterator() for iter.Next(ctx) { rec := iter.Current() fmt.Printf("%s: $%.2f/mo\n", rec.Service, rec.MonthlySavings) } ``` #### Parameters | Parameter | Type | Description | | ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `page` | `int` | Page number, 1-indexed | | `page_size` | `int` | Items per page | | `sort_by` | `string` | Sort field: `recommendation_id`, `service`, `environment`, `monthly_savings`, `annual_savings`, `savings_percentage`, `analysis_period`, `created_at` | | `sort_order` | `string` | `asc` or `desc` | > **Note** > > This list sorts and pages. It takes no filters: to narrow by service, environment, account, tag or status, use [List by provider](#list-by-provider). ### Get recommendation Returns the full detail for one recommendation. **Python** ```python detail = client.recommendations.get("rec_123") ``` **TypeScript** ```typescript const detail = await client.recommendations.get({ recommendation_id: "rec_123" }); ``` **Go** ```go detail, err := client.Recommendations.Get(ctx, "rec_123") ``` ### Get provider overview Returns the same metrics as [Get overview](#get-overview), scoped to a single provider: total spend, available savings, pending savings, and saved-to-date. Use it for a per-provider dashboard header. **Python** ```python overview = client.recommendations.get_provider_overview("aws") ``` **TypeScript** ```typescript const overview = await client.recommendations.getProviderOverview("aws"); ``` **Go** ```go overview, err := client.Recommendations.GetProviderOverview(ctx, "aws") ``` ### List by provider Returns a paginated per-provider recommendations list, filtered by service, environment, account, tag, and display status. Each row includes monthly and annual savings, current spend, and a savings percentage. **Python** ```python page = client.recommendations.list_by_provider( "aws", page=1, page_size=20, service=["EC2", "RDS"], environment=["production"], display_status=["available"], sort_by="monthly_savings", sort_order="desc", ) for rec in page: print(f"{rec.service}: ${rec.monthly_savings}/mo") ``` **TypeScript** ```typescript for await (const rec of await client.recommendations.listByProvider("aws", { page_size: 20, service: ["EC2", "RDS"], environment: ["production"], display_status: ["available"], sort_by: "monthly_savings", sort_order: "desc", })) { console.log(`${rec.service}: $${rec.monthly_savings}/mo`); } ``` **Go** ```go page, err := client.Recommendations.ListByProvider(ctx, "aws", &levelfour.ListByProviderRecommendationsRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(20), Service: []string{"EC2", "RDS"}, Environment: []string{"production"}, DisplayStatus: []string{"available"}, SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListByProviderRecommendationsRequestSortOrder("desc").Ptr(), }) iter := page.Iterator() for iter.Next(ctx) { rec := iter.Current() fmt.Printf("%s: $%.2f/mo\n", rec.Service, rec.MonthlySavings) } ``` #### Parameters | Parameter | Type | Description | | ------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | | `page` | `int` | Page number, 1-indexed | | `page_size` | `int` | Items per page | | `sort_by` | `string` | Sort field: `recommendation_id`, `service`, `environment`, `account`, `tag`, `monthly_savings`, `savings_percentage`, `status` | | `sort_order` | `string` | `asc` or `desc` | | `service` | `string[]` | Filter by service | | `environment` | `string[]` | Filter by environment | | `account` | `string[]` | Filter by account ID | | `tag` | `string[]` | Filter by tag | | `virtual_tag_key` | `string` | A virtual tag's name or id. Keeps the opportunities whose resources carry one of `virtual_tag_value` under it | | `virtual_tag_value` | `string[]` | Values of `virtual_tag_key` to keep. `__unallocated__` keeps the opportunities no config assigned | | `display_status` | `string[]` | Filter by status: `available`, `pending`, `processing`, `optimized`, `rejected`, `unavailable` | [Filtering and sorting](/guides/filtering-and-sorting) has recipes built on these parameters: the "RDS delete candidates" pattern, top-N-by-savings ranking, and CSV export. ### List in-progress Returns every recommendation currently in `processing` status. **Python** ```python processing = client.recommendations.list_in_progress() ``` **TypeScript** ```typescript const processing = await client.recommendations.listInProgress(); ``` **Go** ```go processing, err := client.Recommendations.ListInProgress(ctx) ``` ## Acting on a recommendation These calls change one recommendation, or read who did what to it. Accepting or rejecting one happens in the dashboard, with [`l4 recommendations`](/cli/commands/recommendations), or through the [MCP server](/mcp). Choosing resources, adding rejection feedback, requesting execution and reading the approval queue take a read-write key. The activity timeline and the Automated Savings check work with any key. The examples show the Go SDK and plain HTTP; [SDK Overview](/sdks#sub-clients) lists which languages carry each namespace. ### Choose the resources Limits a recommendation to the resources you keep. Any resource you leave out is removed from it, and the savings figure follows. The list must be a non-empty subset of the resources the recommendation carries. **Go** ```go _, err := client.Recommendations.UpdateResourceSelection(ctx, "REC-1234", &levelfour.ResourceSelectionRequest{ SelectedResources: []string{"vol-0abc1234def567890", "vol-0def5678abc123456"}, }, ) ``` **HTTP** ```bash curl -s -X POST "https://api.levelfour.ai/api/v1/recommendations/REC-1234/resource-selection" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"selected_resources": ["vol-0abc1234def567890", "vol-0def5678abc123456"]}' ``` ### Say why it was rejected Adds a reason to a recommendation that is already rejected. `reason` is one of `operational`, `strategy`, `not_applicable` or `other`, and `explanation` is optional free text. **Go** ```go _, err := client.Recommendations.AddRejectionFeedback(ctx, "REC-1234", &levelfour.RejectionFeedbackRequest{ Reason: "not_applicable", Explanation: levelfour.String("This volume backs a disaster recovery test."), }, ) ``` **HTTP** ```bash curl -s -X POST "https://api.levelfour.ai/api/v1/recommendations/REC-1234/rejection-feedback" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"reason": "not_applicable", "explanation": "This volume backs a disaster recovery test."}' ``` ### Read the activity timeline Returns the recommendation's events in order: who viewed it and every decision taken on it. **Go** ```go activity, err := client.Recommendations.GetRecommendationActivity(ctx, "REC-1234") ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/recommendations/REC-1234/activity" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ### Request execution Puts an accepted recommendation in front of an admin to release. It moves to `pending_approval` until an admin approves it in the dashboard. `implementation_method` picks how the change is delivered: | Value | Delivers the change as | | -------------------- | ---------------------------------------------------------------------- | | `one-click` | Automated Savings: LevelFour applies it through the access you granted | | `iac` | A pull request against your infrastructure-as-code repository | | `one-click-plus-iac` | Both: LevelFour applies it, and a pull request keeps your code in step | | `manual` | Steps your team carries out | **Go** ```go approval, err := client.Recommendations.RequestExecution(ctx, "REC-1234", &levelfour.RequestExecutionBody{ ImplementationMethod: levelfour.String("iac"), }, ) ``` **HTTP** ```bash curl -s -X POST "https://api.levelfour.ai/api/v1/recommendations/REC-1234/execution/request" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"implementation_method": "iac"}' ``` A pull request needs a repository to land in. [Repository binding](/api-reference/repo-binding) chooses it, and Automated Savings needs a [grant](/api-reference/savings-grants) first. ### List what is waiting for approval Returns every recommendation a member has submitted for execution that still needs an admin's release. **Go** ```go waiting, err := client.Recommendations.ListPendingApprovals(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/recommendations/pending-approvals" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ### Check Automated Savings access Returns whether the access Automated Savings needs is in place for this recommendation's account, and the link that sets it up when it is not. **Go** ```go access, err := client.Recommendations.GetAutomatedSavings(ctx, "REC-1234") ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/recommendations/REC-1234/automated-savings" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` [Automated Savings](/connectors/aws/automated-savings) covers the access itself. ## RecommendationItem Returned in paginated list responses. | Field | Type | Description | | -------------------- | --------- | -------------------------------------------------------------------- | | `recommendation_id` | `string` | Unique business identifier | | `service` | `string` | Cloud service name | | `environment` | `string?` | Environment tag | | `virtual_tags` | `object?` | The value of each virtual key on this opportunity, key name to value | | `analysis_period` | `string?` | Analysis time window | | `monthly_savings` | `float` | Estimated monthly savings | | `annual_savings` | `float` | Estimated annual savings | | `current_spending` | `float` | Current monthly spend | | `savings_percentage` | `float` | Savings as percentage of current spend | | `actions` | `object?` | Available actions for this recommendation | ## Find high-value AWS recommendations **Python** ```python from levelfour import LevelFour client = LevelFour() for rec in client.recommendations.list_by_provider( "aws", page_size=20, sort_by="monthly_savings", sort_order="desc", display_status=["available"], ): if rec.monthly_savings > 100: print(f"{rec.recommendation_id}: ${rec.monthly_savings}/mo") ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); for await (const rec of await client.recommendations.listByProvider({ provider_id: "aws", page_size: 20, sort_by: "monthly_savings", sort_order: "desc", display_status: ["available"], })) { if (rec.monthly_savings > 100) { console.log(`${rec.recommendation_id}: $${rec.monthly_savings}/mo`); } } ``` **Go** ```go package main import ( "context" "fmt" "log" "github.com/LevelFourAI/levelfour-go/levelfour" ) func main() { client, err := levelfour.NewClient("") if err != nil { log.Fatal(err) } page, err := client.Recommendations.ListByProvider( context.Background(), "aws", &levelfour.ListByProviderRecommendationsRequest{ PageSize: levelfour.Int(20), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListByProviderRecommendationsRequestSortOrder("desc").Ptr(), DisplayStatus: []string{"available"}, }, ) if err != nil { log.Fatal(err) } iter := page.Iterator() for iter.Next(context.Background()) { rec := iter.Current() if rec.MonthlySavings > 100 { fmt.Printf("%s: $%.2f/mo\n", rec.RecommendationID, rec.MonthlySavings) } } if err := iter.Err(); err != nil { log.Fatal(err) } } ``` ## Next * [Savings](/resources/savings) is the realized side: what was accepted, implemented and measured * [Providers](/resources/providers) is the `providers` namespace: provider-scoped recommendations, realized savings, costs and the filter values you can pass * [Pagination](/pagination) has the iterators, the defaults and the page-size cap * [Error handling](/error-handling) has the codes these methods raise --- # Savings Source: https://docs.levelfour.ai/resources/savings.md These methods read what your accepted recommendations actually saved. [Get summary](#get-summary) returns the aggregate. [List savings](#list-savings) returns one row per implemented saving, carrying the cost before and after the optimization, the period, and the return on investment. [Pagination](/pagination) has the defaults, the page-size cap, and the auto-paginating iterators the examples below rely on. ## Get summary Returns the aggregate across every realized saving: total saved, the monthly savings rate, and the split by provider. **Python** ```python summary = client.recommendations.audit.get_summary() ``` **TypeScript** ```typescript const summary = await client.recommendations.audit.getSummary(); ``` **Go** ```go summary, err := client.Recommendations.Audit.GetSummary(ctx) ``` ## List savings Returns a page of implemented savings, filtered by date, provider, service, environment or account. **Python** ```python page = client.recommendations.audit.list( page=1, page_size=50, sort_by="monthly_savings", sort_order="desc", provider="aws", start="2025-01-01", end="2025-03-31", ) for item in page: print(f"{item.service}: ${item.monthly_savings}/mo (ROI: {item.roi}x)") ``` **TypeScript** ```typescript for await (const item of await client.recommendations.audit.list({ page_size: 50, sort_by: "monthly_savings", sort_order: "desc", provider: "aws", start: "2025-01-01", end: "2025-03-31", })) { console.log(`${item.service}: $${item.monthly_savings}/mo`); } ``` **Go** ```go page, err := client.Recommendations.Audit.List(ctx, &levelfour.ListAuditRequest{ PageSize: levelfour.Int(50), SortBy: levelfour.String("monthly_savings"), SortOrder: levelfour.ListAuditRequestSortOrder("desc").Ptr(), Provider: levelfour.String("aws"), Start: levelfour.String("2025-01-01"), End: levelfour.String("2025-03-31"), }) iter := page.Iterator() for iter.Next(ctx) { item := iter.Current() fmt.Printf("%s: $%.2f/mo\n", item.Service, item.MonthlySavings) } ``` ### Parameters | Parameter | Type | Description | | ------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `page` | `int` | Page number, 1-indexed | | `page_size` | `int` | Items per page | | `sort_by` | `string` | Sort field: `period`, `account_id`, `environment`, `service`, `usage_type`, `usage_quantity`, `usage_unit`, `pre_optimization_cost`, `post_optimization_cost`, `monthly_savings`, `roi`, `approved_by` | | `sort_order` | `string` | `asc` or `desc` | | `start` | `string` | Start date (`YYYY-MM-DD` or `YYYY-MM`) | | `end` | `string` | End date (`YYYY-MM-DD` or `YYYY-MM`) | | `preset` | `string` | Date preset: `30D`, `6M`, or `12M` | | `provider` | `string` | Filter by provider: `aws`, `gcp`, `azure`, `k8s` | | `service` | `string[]` | Filter by service(s) | | `environment` | `string[]` | Filter by environment(s) | | `account_id` | `string[]` | Filter by account ID(s) | > **Warning** > > `preset` takes precedence over `start` and `end`. Send all three and the window you get back is the preset's, not the dates you typed. ## SavedItem The row shape [List savings](#list-savings) returns. | Field | Type | Description | | ------------------------ | --------- | -------------------------------------------------------------------------- | | `audit_id` | `int` | The row's id. [Get one realized saving](#get-one-realized-saving) takes it | | `recommendation_id` | `string` | The recommendation that produced this saving | | `period` | `string` | Savings period identifier | | `account_id` | `string?` | Cloud account ID | | `environment` | `string?` | Environment tag | | `service` | `string` | Cloud service name | | `usage_type` | `string` | Type of usage | | `usage_quantity` | `float` | Quantity of usage | | `usage_unit` | `string` | Unit of usage | | `approved_by` | `string?` | Who approved the optimization | | `pre_optimization_cost` | `float` | Cost before optimization | | `post_optimization_cost` | `float` | Cost after optimization | | `monthly_savings` | `float` | Monthly savings amount | | `annual_savings` | `float` | Annual savings amount | | `roi` | `float` | Return on investment | > **Warning** > > Each row carries a full monthly rate. Adding `monthly_savings` down a filtered list gives you a run rate, not the cash saved inside the window you filtered. ## Get one realized saving Returns one audited saving in full: who approved it, how it was applied and when, the change recorded in your cloud account, the savings measured after 7 and 30 days, and the saving broken down line by line per usage type. The id is the numeric `audit_id` a row from [List savings](#list-savings) carries. **Go** ```go detail, err := client.Audit.GetRealizedAuditDetail(ctx, 4821) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/recommendations/audit/realized/4821" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Savings by provider [Get summary](#get-summary) returns the split by provider in one call. For one provider's totals on their own, the [Providers](/resources/providers) page owns [Get realized savings summary](/resources/providers#get-realized-savings-summary), which the example below calls once per provider. **Python** ```python from levelfour import LevelFour client = LevelFour() for provider in ["aws", "gcp", "azure"]: summary = client.recommendations.audit.get_provider_summary(provider) print(f"{provider}: {summary}") ``` **TypeScript** ```typescript import { LevelFourClient } from "levelfour"; const client = new LevelFourClient(); for (const provider of ["aws", "gcp", "azure"]) { const summary = await client.recommendations.audit.getProviderSummary({ provider_id: provider, }); console.log(`${provider}:`, summary); } ``` **Go** ```go for _, provider := range []string{"aws", "gcp", "azure"} { summary, err := client.Recommendations.Audit.GetProviderSummary(ctx, provider) if err != nil { log.Fatal(err) } fmt.Printf("%s: %v\n", provider, summary) } ``` ## Next * [Recommendations](/resources/recommendations) is the potential side: opportunities LevelFour has found and nobody has applied yet * [Providers](/resources/providers) scopes realized savings, recommendations and costs to one cloud * [Pagination](/pagination) has the defaults behind `page` and `page_size`, and the iterators that page for you * [`/api-reference/audit`](/api-reference/audit) is the generated REST reference for the endpoints behind these methods --- # Commitments Source: https://docs.levelfour.ai/resources/commitments.md The commitments methods read the Reserved Instances and Savings Plans your organization holds: each one's state, how much of it you use, how much of your eligible bill it covers, and what replacing it costs when it expires. They are reads, and any key can call them. The examples show the Go SDK and plain HTTP. A commitment id is the id AWS gives it. A Savings Plan's is an ARN, which contains colons and a slash, so quote it on a command line and URL-encode it in a path. ## Methods | Method | Returns | | --------------------------------------------------------- | --------------------------------------------------------------------------------------- | | [Get overview](#get-overview) | Your effective savings rate and the headline Reserved Instance and Savings Plan figures | | [Get inventory](#get-inventory) | Every commitment you hold, one row each, with the state AWS reports | | [Get utilization](#get-utilization) | How much of what you bought you used, per service or plan type | | [Get coverage rates](#get-coverage-rates) | How much of the eligible bill a commitment covers, per service and per account | | [Get effective savings rate](#get-effective-savings-rate) | What your usage cost against what it would have cost on demand | | [Get portfolio](#get-portfolio) | One row per commitment across every account, with expiry and utilization trend | | [Get by service](#get-by-service) | Utilization and coverage per service and instrument | | [Get uncovered base](#get-uncovered-base) | On-demand spend no commitment covers, split for sizing | | [Get recommendations](#get-recommendations) | Commitments worth buying | | [Get contracts](#get-contracts) | Marketplace and contract commitments that bill like one | | [Get one commitment](#get-one-commitment) | The detail and the renewal plan for one commitment | | [Price a renewal](#price-a-renewal) | Every term an expiring commitment could be replaced with, and what each costs | ## Get overview Returns your effective savings rate and the aggregate figures for Reserved Instances and Savings Plans. AWS carries real figures. Other providers return zeros. **Go** ```go overview, err := client.Commitments.GetOverview(ctx, &levelfour.GetOverviewAPIV1CommitmentsOverviewGetRequest{ CommitmentType: levelfour.String("sp"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/overview?commitment_type=sp" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `commitment_type` is `all` (default), `ri` or `sp`. `provider` defaults to `aws`. ## Get inventory Returns every commitment of one instrument, one row each. The state is the one AWS reports, not one worked out from the end date: a plan that starts next month is `queued`, and one whose payment failed is `payment-failed`, neither running nor expired. A field only some services carry arrives as null, not zero. **Go** ```go reservations, err := client.Commitments.GetInventory(ctx, &levelfour.GetInventoryAPIV1CommitmentsInventoryGetRequest{ Instrument: "ri", Service: levelfour.String("rds"), State: levelfour.String("active"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/inventory?instrument=ri&service=rds&state=active" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` | Parameter | Type | Description | | ------------ | -------- | -------------------------------------------------------------------------------------- | | `instrument` | `string` | Required. `ri` for Reserved Instances, `sp` for Savings Plans | | `service` | `string` | Limit to one service | | `state` | `string` | `active`, `queued`, `queued-deleted`, `payment-pending`, `payment-failed` or `retired` | | `provider` | `string` | Defaults to `aws` | ## Get utilization Returns how much of what you bought you used, over a window. Reservations come back one entry per service and plans one per plan type, each measured on its own. They are never averaged together: EC2 and RDS reservations are sized in vCPU while Redshift, ElastiCache, OpenSearch and MemoryDB are sized in nodes, so one average would describe neither. Each entry's headline is used hours divided by purchased hours. **Go** ```go utilization, err := client.Commitments.GetUtilization(ctx, &levelfour.GetUtilizationAPIV1CommitmentsUtilizationGetRequest{ Type: levelfour.String("ri"), Start: levelfour.String("2026-06-01"), End: levelfour.String("2026-08-31"), Granularity: levelfour.String("monthly"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/utilization?type=ri&start=2026-06-01&end=2026-08-31&granularity=monthly" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` | Parameter | Type | Description | | -------------- | -------- | ------------------------------ | | `type` | `string` | `ri` (default) or `sp` | | `start`, `end` | `string` | `YYYY-MM-DD` | | `granularity` | `string` | `daily` (default) or `monthly` | ## Get coverage rates Returns how much of the bill that could be covered is covered: per service, and the covered spend per account that consumed it, with the on-demand remainder beside it so the percentage shows what it is a percentage of. Figures are weighted by the dollars behind them, so a small reservation cannot move the number as much as a large one. **Go** ```go coverage, err := client.Commitments.GetCoverageRates(ctx, &levelfour.GetCoverageRatesAPIV1CommitmentsCoverageRatesGetRequest{}) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/coverage-rates" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` A provider LevelFour has not measured yet reports `measured: false` rather than zero coverage. > **Warning** > > `GetCoverage` (`/api/v1/commitments/coverage`) is deprecated. It has one row per commitment, so usage nothing covers never appears in it. Use coverage rates. ## Get effective savings rate Returns what your usage would have cost at public on-demand rates against what it actually cost, for the organization and for each account. The rate is split into what a negotiated agreement earns and what commitments earn, so an account that owns no commitment does not look well covered only because of a discount. **Go** ```go esr, err := client.Commitments.GetEsr(ctx, &levelfour.GetEsrAPIV1CommitmentsEsrGetRequest{ Period: levelfour.String("2026-08"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/esr?period=2026-08" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `scope` is `eligible` (default), which counts only spend a commitment could cover, or `all`. `period` is a `YYYY-MM` month. ## Get portfolio Returns one row per commitment across every connected account. Each row names the account that holds it and the accounts that consume it, with the expiry time, the utilization trend, and, kept apart, what a renewal protects and what resizing would save. **Go** ```go portfolio, err := client.Commitments.GetPortfolio(ctx, &levelfour.GetPortfolioAPIV1CommitmentsPortfolioGetRequest{ Basis: levelfour.String("net"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/portfolio?basis=net" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` `basis` is `net` (default), what you pay, or `list`, public prices. ## Get by service Returns a service by instrument grid of utilization, coverage, unused and uncovered values, one row for each service that holds at least one commitment. **Go** ```go byService, err := client.Commitments.GetByService(ctx, &levelfour.GetByServiceAPIV1CommitmentsByServiceGetRequest{}) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/by-service" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get uncovered base Returns the on-demand spend no commitment covers, split by platform and by how much it varies. Each part carries its hourly floor beside its average, because a commitment sized to the average of a spiky base commits more than the base can sustain. **Go** ```go uncovered, err := client.Commitments.GetUncovered(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/uncovered" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get recommendations Returns the commitments worth buying. `commitment_type` is `all` (default), `ri` or `sp`. **Go** ```go toBuy, err := client.Commitments.GetRecommendations(ctx, &levelfour.GetRecommendationsAPIV1CommitmentsRecommendationsGetRequest{ CommitmentType: levelfour.String("sp"), }) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/recommendations?commitment_type=sp" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get contracts Returns commitments that bill like one but appear in no provider commitment view, such as a Marketplace contract, with the contracted floor kept apart from the metered charge that bills on top. **Go** ```go contracts, err := client.Commitments.GetContracts(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/contracts" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get one commitment The detail of one commitment, and its renewal plan: what to buy to replace it and when. The plan gives the moment after which buying is safe, not only a date, because a replacement bills from the moment it is bought. It also lists pending changes that should land first, so their capacity is not locked in for another term. **Go** ```go detail, err := client.Commitments.GetDetail(ctx, "arn:aws:savingsplans::123456789012:savingsplan/abcd1234") plan, err := client.Commitments.GetRenewalPlan(ctx, "arn:aws:savingsplans::123456789012:savingsplan/abcd1234") ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/renewal-plan/arn%3Aaws%3Asavingsplans%3A%3A123456789012%3Asavingsplan%2Fabcd1234" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Price a renewal Three reads price the replacement for an expiring commitment. Go SDK v0.2.0 predates them, so the example is plain REST. | Endpoint | Returns | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/commitments/renewal-options/{commitment_id}` | The contract priced again as it stands, beside every other term it is sold on today, each at a range of quantities, with exactly one recommended. Pass `quantity` to price every offering at that quantity instead. A reservation is sold in whole units, so a fractional quantity is a 422 | | `GET /api/v1/commitments/renewal-quote/{commitment_id}` | What replacing it costs today, priced from the current offering. A lookup that failed comes back in `error_message`, so an unpriced renewal reads as unpriced rather than as zero | | `GET /api/v1/commitments/renewal/{commitment_id}` | The renewal purchase already raised for this commitment, or a 404 when there is none | ```bash curl -s "https://api.levelfour.ai/api/v1/commitments/renewal-options/arn%3Aaws%3Asavingsplans%3A%3A123456789012%3Asavingsplan%2Fabcd1234" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` > **Note** > > Raising the renewal itself happens in the dashboard, not over the API. It commits the organization to a new term, so only a signed-in person with permission to renew can raise it, and the purchase it creates waits for approval like any other saving. [Renewals](/commitments/renewals) walks through it. ## Next * [`l4 commitments`](/cli/commands/commitments) reads the same data from a terminal * [API reference](/api-reference/commitments) is every parameter and response field * [Recommendations](/resources/recommendations) covers acting on a purchase once it is raised --- # Cost Alerts Source: https://docs.levelfour.ai/resources/cost-alerts.md The cost alert methods manage the rules [Cost alerts](/cost-alerts) describes. Reads take any key. Creating, changing, pausing and deleting take a read-write key. The examples show the Go, Python and TypeScript SDKs and plain HTTP. Python and TypeScript carry these methods from 0.2.0, and Go from v0.2.0. ## Methods | Method | Returns | | --------------------------------------------------------- | --------------------------------------------------------- | | [List alerts](#list-alerts) | Every alert, with the live state of what each one watches | | [List sources](#list-sources) | What an alert can watch | | [Create an alert](#create-an-alert) | The stored alert | | [Read one alert](#read-one-alert) | One alert | | [Change an alert](#change-an-alert) | The alert as it now reads | | [Pause and resume](#pause-and-resume) | The alert, with who paused it | | [Delete an alert](#delete-an-alert) | The id of the alert that is gone | | [Alert events](#alert-events) | What one alert has said, newest first | | [Suggestions for an account](#suggestions-for-an-account) | Rules the account's own billing history supports | ## List alerts Returns every alert in your organization. `watching` holds the live state of each thing an alert watches, as the last check left it: `ok`, `pending` or `firing`. **Go** ```go alerts, err := client.CostAlerts.ListAlerts(ctx) ``` **Python** ```python alerts = client.cost_alerts.list_alerts() ``` **TypeScript** ```typescript const alerts = await client.costAlerts.listAlerts(); ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/cost-alerts" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## List sources Returns every slice an alert can watch, as `id`, `label`, `group` and `provider`. `group` is `accounts`, `views` or `commitments`, and the `id` is what an alert's `source_id` takes. **Go** ```go sources, err := client.CostAlerts.ListSources(ctx) ``` **Python** ```python sources = client.cost_alerts.list_sources() ``` **TypeScript** ```typescript const sources = await client.costAlerts.listSources(); ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/cost-alerts/sources" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Create an alert Creates an alert. `params` is the rule: `kind` is `budget`, `changes` or `commitment`, and [Cost alerts](/cost-alerts#types) says when each one fires. The example fires when month-to-date spend passes 80 percent of a 40,000 budget. An alert needs a member of your organization among its recipients, or a Slack channel. **Go** ```go budget := func(amount float64) *levelfour.BudgetParamsInput { return &levelfour.BudgetParamsInput{ Budget: &levelfour.BudgetParamsInputBudget{Double: amount}, Reset: "monthly", Rules: []*levelfour.BudgetRuleInput{ {Amount: &levelfour.BudgetRuleInputAmount{Double: 80}, Unit: "percent"}, }, } } created, err := client.CostAlerts.CreateAlert(ctx, &levelfour.CreateAlertRequest{ Name: "Production budget", SourceID: "123456789012", Params: &levelfour.CreateAlertRequestParams{Budget: budget(40000)}, Recipients: []*levelfour.AlertRecipient{{Name: "Ana", Email: "ana@example.com", Member: true}}, }) alertID := created.Data.ID ``` **Python** ```python from levelfour.cost_alerts import CreateAlertRequestParams_Budget from levelfour.types import AlertRecipient, BudgetRuleInput monthly = dict(reset="monthly", rules=[BudgetRuleInput(amount=80, unit="percent")]) created = client.cost_alerts.create_alert( name="Production budget", source_id="123456789012", params=CreateAlertRequestParams_Budget(budget=40000, **monthly), recipients=[AlertRecipient(name="Ana", email="ana@example.com", member=True)], ) alert_id = created.data.id ``` **TypeScript** ```typescript const monthly = (budget: number) => ({ kind: "budget" as const, budget, reset: "monthly" as const, rules: [{ amount: 80, unit: "percent" as const }], }); const created = await client.costAlerts.createAlert({ name: "Production budget", source_id: "123456789012", params: monthly(40000), recipients: [{ name: "Ana", email: "ana@example.com", member: true }], }); const alert_id = created.data.id; ``` **HTTP** ```bash curl -s -X POST "https://api.levelfour.ai/api/v1/cost-alerts" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Production budget", "source_id": "123456789012", "params": {"kind": "budget", "budget": 40000, "reset": "monthly", "rules": [{"amount": 80, "unit": "percent"}]}, "recipients": [{"name": "Ana", "email": "ana@example.com", "member": true}] }' ``` Over HTTP, `slack_channel_ids` takes up to 10 Slack channel ids to post in. Left out, the alert posts in your organization's default channel, if one is set. The SDKs do not carry the field yet, so name channels over HTTP or in the dashboard. ## Read one alert **Go** ```go alert, err := client.CostAlerts.GetAlert(ctx, alertID) ``` **Python** ```python alert = client.cost_alerts.get_alert(alert_id) ``` **TypeScript** ```typescript const alert = await client.costAlerts.getAlert({ alert_id }); ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/cost-alerts/$ALERT_ID" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Change an alert Only the fields you send change. `params` replaces the whole rule, so to move one threshold, send the rule back with that figure changed. The API refuses a field sent as `null`. This raises the budget to 50,000: **Go** ```go _, err = client.CostAlerts.UpdateAlert(ctx, alertID, &levelfour.UpdateAlertRequest{ Params: &levelfour.UpdateAlertRequestParams{Budget: budget(50000)}, }) ``` **Python** ```python from levelfour.cost_alerts import UpdateAlertRequestParams_Budget client.cost_alerts.update_alert(alert_id, params=UpdateAlertRequestParams_Budget(budget=50000, **monthly)) ``` **TypeScript** ```typescript await client.costAlerts.updateAlert({ alert_id, params: monthly(50000) }); ``` **HTTP** ```bash curl -s -X PATCH "https://api.levelfour.ai/api/v1/cost-alerts/$ALERT_ID" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" -H "Content-Type: application/json" \ -d '{"params": {"kind": "budget", "budget": 50000, "reset": "monthly", "rules": [{"amount": 80, "unit": "percent"}]}}' ``` ## Pause and resume Pausing is a change to `enabled`. `false` silences the alert and records who paused it in `paused_by`, and `true` arms it again. A paused alert keeps its history. **Go** ```go enabled := false // true resumes it _, err = client.CostAlerts.UpdateAlert(ctx, alertID, &levelfour.UpdateAlertRequest{Enabled: &enabled}) ``` **Python** ```python client.cost_alerts.update_alert(alert_id, enabled=False) # True resumes it ``` **TypeScript** ```typescript await client.costAlerts.updateAlert({ alert_id, enabled: false }); // true resumes it ``` **HTTP** ```bash curl -s -X PATCH "https://api.levelfour.ai/api/v1/cost-alerts/$ALERT_ID" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" -H "Content-Type: application/json" \ -d '{"enabled": false}' ``` ## Delete an alert Deletes the alert and every event it recorded. Nothing brings either back, so pause an alert you might want again. **Go** ```go _, err = client.CostAlerts.DeleteAlert(ctx, alertID) ``` **Python** ```python client.cost_alerts.delete_alert(alert_id) ``` **TypeScript** ```typescript await client.costAlerts.deleteAlert({ alert_id }); ``` **HTTP** ```bash curl -s -X DELETE "https://api.levelfour.ai/api/v1/cost-alerts/$ALERT_ID" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Alert events Returns one entry per firing and per recovery, newest first, with the figure observed, the threshold it crossed and whether anyone was notified. `limit` is 1 to 200 and defaults to 50. **Go** ```go events, err := client.CostAlerts.ListAlertEvents(ctx, alertID, &levelfour.ListAlertEventsAPIV1CostAlertsAlertIDEventsGetRequest{Limit: levelfour.Int(20)}, ) ``` **Python** ```python events = client.cost_alerts.list_alert_events(alert_id, limit=20) ``` **TypeScript** ```typescript const events = await client.costAlerts.listAlertEvents({ alert_id, limit: 20 }); ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/cost-alerts/$ALERT_ID/events?limit=20" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Suggestions for an account Returns rules in the same grammar `params` takes, computed from the account's own billing history, with the baseline behind them. An account with too little history returns no suggestion rather than a number nobody can check. **Go** ```go suggestions, err := client.CostAlerts.SuggestionsForAccount(ctx, "123456789012") ``` **Python** ```python suggestions = client.cost_alerts.suggestions_for_account("123456789012") ``` **TypeScript** ```typescript const suggestions = await client.costAlerts.suggestionsForAccount({ account_id: "123456789012" }); ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/cost-alerts/suggestions/123456789012" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Related * [Cost alerts](/cost-alerts): the types of rule, where alerts arrive, and how often they are checked * [`l4 alerts`](/cli/commands/alerts): the same operations from the terminal * [Cost Alerts API reference](/api-reference/cost-alerts): every field of every endpoint --- # Accounts Source: https://docs.levelfour.ai/resources/accounts.md The accounts methods read what is connected to your organization: cloud accounts, what LevelFour can do in each, the modules you have, and the work tools you have linked. Reads take any key. Disconnecting a work tool takes a read-write key. The examples show the Go SDK and plain HTTP. Connecting a cloud account or a work tool happens in the dashboard, because each one ends in the provider's own consent screen. [Connect Providers](/connectors) and [Integrations](/integrations) walk through them. ## Methods | Method | Returns | | --------------------------------------------------- | --------------------------------------------------------------- | | [List connected accounts](#list-connected-accounts) | Every cloud account connected to your organization | | [List connections](#list-connections) | What LevelFour can do in each account | | [List modules](#list-modules) | The modules your organization has, and their state | | [Work tool connections](#work-tool-connections) | Connected GitHub, GitLab, Jira and Slack, and disconnecting one | ## List connected accounts Returns the cloud accounts connected to your organization, paginated. `page_size` goes up to 200 and defaults to 50. **Go** ```go accounts, err := client.Accounts.ListConnectedAccounts(ctx, &levelfour.ListConnectedAccountsAPIV1AccountsGetRequest{ PageSize: levelfour.Int(200), }, ) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/accounts?page_size=200" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` Each AWS account also reports its [detailed cost data](/connectors/aws/detailed-cost-data) state: whether the export is active, where it delivers, and when LevelFour last read it. ## List connections Returns, for each connected account, which LevelFour capabilities are ready. A capability reads `true` when it is on, `false` when it needs setup, and `null` when LevelFour could not tell. One that needs setup carries the link that sets it up, such as the stack for [Automated Savings](/connectors/aws/automated-savings). **Go** ```go connections, err := client.Accounts.ListConnections(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/accounts/connections" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## List modules Returns the modules your organization has, with each one's state (`inactive`, `trial`, `active`, `past_due`, `revoked` or `beta`), plan tier and dates. A module you do not have is why a page or a method is missing for you. **Go** ```go modules, err := client.Accounts.ListCustomerModules(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/accounts/modules" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Work tool connections List the work tools connected to your organization. GitHub reports its App installations, and the others their connections. **Go** ```go github, err := client.Accounts.ListGithubInstallations(ctx) gitlab, err := client.Accounts.ListGitlabConnections(ctx) jira, err := client.Accounts.ListJiraConnections(ctx) slack, err := client.Accounts.ListSlackConnections(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/accounts/gitlab/connections" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` Disconnecting a GitLab, Jira or Slack connection takes its id from the list. Disconnecting GitLab also removes the webhook LevelFour added, and disconnecting Slack stops the bot answering in that workspace. **Go** ```go _, err := client.Accounts.DisconnectSlackConnection(ctx, "connection_id") ``` **HTTP** ```bash curl -s -X DELETE "https://api.levelfour.ai/api/v1/accounts/slack/connections/connection_id" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` A GitHub App is removed from GitHub itself. [GitHub](/integrations/github) has the steps. ## Next * [Providers](/resources/providers) lists the providers you have connected, with provider-scoped reads * [Integrations](/api-reference/integrations) files a task in a connected work tool, such as a Jira issue from a saving * [API reference](/api-reference/accounts) is every parameter and response field --- # Google Cloud Source: https://docs.levelfour.ai/resources/google-cloud.md These methods read what LevelFour knows about your Google Cloud estate beyond the bill: resources, utilization, budgets and access. Cost, recommendations and savings for Google Cloud use the same methods as every other provider, with `gcp` as the provider ID; [Costs](/resources/costs#google-cloud-credits) covers the credits basis they take. Any key can call these reads. The examples show the Go SDK and plain HTTP. > **Note** > > Your LevelFour contact turns on the resource inventory, utilization metrics, budgets and the access check for your organization. Until then those reads come back empty. [Get data availability](#get-data-availability) says which ones hold data. ## Methods | Method | Returns | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | [Get data availability](#get-data-availability) | Which Google Cloud datasets hold data for your organization | | [Get inventory breakdown](#get-inventory-breakdown) | Resource counts by type, region or project | | [Get waste signals](#get-waste-signals) | Resources that bill while doing nothing | | [Get label hygiene](#get-label-hygiene) | How many resources carry the labels you care about | | [Get utilization](#get-utilization) | Utilization bands for VMs, Cloud SQL and GKE, one resource's daily series, and which projects answered | | [Get budgets](#get-budgets) | Your Cloud Billing budgets, with spend from the billing export | | [Get access](#get-access) | What LevelFour's service account can read | ## Get data availability Returns, for each Google Cloud dataset LevelFour reads, whether it holds data for your organization. The dashboard's Google Cloud board shows a card only when the dataset behind it holds data, so this is the same answer the board uses. **Go** ```go availability, err := client.GoogleCloud.GetDataAvailability(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/data-availability" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get inventory breakdown Counts your Google Cloud resources, grouped by `type` (default), `region` or `project`. **Go** ```go byRegion, err := client.GoogleCloud.GetInventoryBreakdown(ctx, &levelfour.GetInventoryBreakdownAPIV1ProvidersGcpInventoryBreakdownGetRequest{ By: levelfour.GetInventoryBreakdownAPIV1ProvidersGcpInventoryBreakdownGetRequestBy("region").Ptr(), }, ) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/inventory/breakdown?by=region" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get waste signals Returns resources that bill while doing nothing: stopped VMs whose disks still bill, unattached disks, reserved addresses nothing uses, and snapshots older than 90 days. **Go** ```go waste, err := client.GoogleCloud.GetWasteSignals(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/inventory/waste-signals" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get label hygiene Returns how many resources carry each label key, and how many carry none. Pass `keys` to measure the keys your allocation depends on. System labels, the ones starting `goog-`, never count as labeled. **Go** ```go hygiene, err := client.GoogleCloud.GetLabelHygiene(ctx, &levelfour.GetLabelHygieneAPIV1ProvidersGcpInventoryLabelHygieneGetRequest{ Keys: []string{"team", "env"}, }, ) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/inventory/label-hygiene?keys=team&keys=env" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get utilization Three reads cover utilization from Cloud Monitoring. The summary puts VMs, Cloud SQL instances and GKE workloads into utilization bands over the last 30 days. The series is one resource's daily values. Coverage says which projects and metrics Cloud Monitoring answered for, so a missing band reads as missing data rather than as idle. **Go** ```go summary, err := client.GoogleCloud.GetUtilizationSummary(ctx) series, err := client.GoogleCloud.GetUtilizationSeries(ctx, &levelfour.GetUtilizationSeriesAPIV1ProvidersGcpUtilizationSeriesGetRequest{ ResourceID: "projects/acme-prod/zones/us-central1-a/instances/web-1", Metric: levelfour.String("memory_utilization"), Days: levelfour.Int(90), }, ) coverage, err := client.GoogleCloud.GetUtilizationCoverage(ctx) ``` **HTTP** ```bash curl -s -G "https://api.levelfour.ai/api/v1/providers/gcp/utilization/series" \ --data-urlencode "resource_id=projects/acme-prod/zones/us-central1-a/instances/web-1" \ --data-urlencode "metric=memory_utilization" \ --data-urlencode "days=90" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` | Series parameter | Type | Description | | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `resource_id` | `string` | Required. The resource the summary names | | `metric` | `string` | `cpu_utilization` (default), `memory_utilization` for VMs running the Ops Agent, or `memory_request_utilization` for GKE | | `days` | `int` | 1 to 400 (defaults to 30) | ## Get budgets Returns your Cloud Billing budgets, each with the spend LevelFour reads from the billing export against it. A budget whose scope the export cannot reproduce exactly is marked approximate. Reading budgets needs the optional `roles/billing.viewer` grant from the [billing connect command](/connectors/gcp/billing-export#connect-the-export-to-levelfour). **Go** ```go budgets, err := client.GoogleCloud.GetBudgets(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/budgets" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Get access Returns what LevelFour's service account can read in Google Cloud, checked against Google's own permission test, beside what the connect command reported it applied. Use it when a read comes back empty to see whether a grant is missing. **Go** ```go access, err := client.GoogleCloud.GetAccess(ctx) ``` **HTTP** ```bash curl -s "https://api.levelfour.ai/api/v1/providers/gcp/access" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" ``` ## Next * [Connect Google Cloud](/connectors/gcp) is the connection these reads depend on * [Costs](/resources/costs) has Google Cloud spend, the credits basis, cost by labels and invoice months * [API reference](/api-reference/google-cloud) is every parameter and response field --- # Webhooks Source: https://docs.levelfour.ai/webhooks.md LevelFour delivers webhooks through [Svix](https://svix.com). When events occur in your account, LevelFour sends HTTPS POST requests to your registered endpoints with a signed payload. The snippets below use the LevelFour SDK clients. [SDKs](/sdks) covers installing one and building `client`. ## Register an endpoint **Python** ```python endpoint = client.webhooks.register( url="https://your-app.com/webhooks/levelfour", event_types=["recommendation.accepted", "optimization.completed"], description="Production webhook", ) ``` **TypeScript** ```typescript const endpoint = await client.webhooks.register({ url: "https://your-app.com/webhooks/levelfour", event_types: ["recommendation.accepted", "optimization.completed"], }); ``` **Go** ```go endpoint, err := client.Webhooks.Register(ctx, &levelfour.RegisterEndpointRequest{ URL: "https://your-app.com/webhooks/levelfour", EventTypes: []string{"recommendation.accepted", "optimization.completed"}, }) ``` The call takes: | Parameter | Type | Required | Description | | ------------- | ---------- | -------- | -------------------------- | | `url` | `string` | Yes | HTTPS endpoint URL | | `event_types` | `string[]` | Yes | Events to subscribe to | | `description` | `string` | No | Human-readable description | `url` must be HTTPS. [Webhooks in the API reference](/api-reference/webhooks) has the full request and response schema. ## Manage endpoints ### List endpoints **Python** ```python endpoints = client.webhooks.list() ``` **TypeScript** ```typescript const endpoints = await client.webhooks.list(); ``` **Go** ```go endpoints, err := client.Webhooks.List(ctx) ``` ### Delete an endpoint **Python** ```python client.webhooks.delete("ep_123") ``` **TypeScript** ```typescript await client.webhooks.delete({ endpoint_id: "ep_123" }); ``` **Go** ```go _, err := client.Webhooks.Delete(ctx, "ep_123") ``` ## Events | Event | Description | | ------------------------------------------ | ----------------------------------------------------------- | | `recommendation.terms_accepted` | Someone accepted the terms for applying a recommendation | | `recommendation.accepted` | A savings recommendation was accepted | | `recommendation.rejected` | A savings recommendation was rejected | | `recommendation.rejection_reason_added` | A reason was added to a rejected recommendation | | `recommendation.implementation_method_set` | Someone chose how a recommendation is delivered | | `execution.approval_requested` | A member submitted a recommendation for an admin to release | | `optimization.started` | An automated optimization has started processing | | `optimization.completed` | An automated optimization finished successfully | | `optimization.failed` | An automated optimization failed | ## Payloads ### recommendation.accepted / recommendation.rejected ```json { "recommendation_id": "REC-1234", "saving_acceptance": "accepted", "saving_accepted_by": "user@example.com", "saving_accepted_at": "2025-10-15T10:00:00", "rejection_reason": null, "rejection_explanation": null, "status": "optimized" } ``` A rejected event carries `rejection_reason` and `rejection_explanation`, and its `status` is `rejected`. ### recommendation.terms\_accepted ```json { "recommendation_id": "REC-1234", "terms_accepted_by": "user@example.com", "terms_accepted_at": "2025-10-15T10:00:00Z" } ``` ### recommendation.rejection\_reason\_added ```json { "recommendation_id": "REC-1234", "saving_acceptance": "rejected", "rejection_reason": "not_applicable", "rejection_explanation": "This volume backs a disaster recovery test." } ``` `rejection_reason` is one of `operational`, `strategy`, `not_applicable` or `other`. ### recommendation.implementation\_method\_set ```json { "recommendation_id": "REC-1234", "implementation_method": "iac", "status": "pending" } ``` ### execution.approval\_requested ```json { "recommendation_id": "REC-1234", "execution_request_status": "pending_approval", "execution_request_by": "user@example.com" } ``` Subscribe an endpoint your approvers watch to this event. Without it, a request waits in the dashboard until an admin opens it. ### optimization.started ```json { "recommendation_id": "REC-1234", "status": "processing", "implementation_method": "one-click", "completed_at": "2025-10-15T10:05:00Z", "message": "Optimization started" } ``` The `implementation_method` field can be: `one-click`, `iac`, `one-click-plus-iac`, or `manual`. [`l4 rec execute`](/cli/commands/recommendations/execute) has a table of what each one does. ### optimization.completed ```json { "recommendation_id": "REC-1234", "status": "optimized", "implementation_method": "one-click" } ``` ### optimization.failed ```json { "recommendation_id": "REC-1234", "status": "failed", "implementation_method": "one-click" } ``` ## Verify the signature > **Warning** > > Always verify webhook signatures before processing events. Your endpoint is a public URL, so a handler that skips this acts on whatever reaches it. Every webhook request carries these headers for HMAC-SHA256 signature verification: | Header | Description | | ------------------- | ------------------------------------- | | `webhook-id` | Unique message ID (for deduplication) | | `webhook-timestamp` | Unix timestamp in seconds | | `webhook-signature` | `v1,` | > **Note** > > Legacy Svix headers (`svix-id`, `svix-timestamp`, `svix-signature`) are also supported. **Python** ```python from levelfour.webhooks.verifier import WebhookVerifier, WebhookVerificationError verifier = WebhookVerifier("whsec_your_signing_secret") try: payload = verifier.verify( payload=request_body, headers={ "webhook-id": headers["webhook-id"], "webhook-timestamp": headers["webhook-timestamp"], "webhook-signature": headers["webhook-signature"], }, ) handle_event(payload) except WebhookVerificationError: return Response(status_code=400) ``` **TypeScript** ```typescript import { WebhookVerifier, WebhookVerificationError } from "levelfour"; const verifier = new WebhookVerifier("whsec_your_signing_secret"); try { const payload = verifier.verify(requestBody, { "webhook-id": headers["webhook-id"], "webhook-timestamp": headers["webhook-timestamp"], "webhook-signature": headers["webhook-signature"], }); handleEvent(payload); } catch (err) { if (err instanceof WebhookVerificationError) { res.status(400).json({ error: "Invalid signature" }); } } ``` **Go** ```go import ( "io" "net/http" "github.com/LevelFourAI/levelfour-go/levelfour/webhooks" ) verifier, err := webhooks.NewVerifier("whsec_your_signing_secret") if err != nil { log.Fatal(err) } http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } payload, err := verifier.Verify(r.Header, body) if err != nil { http.Error(w, "invalid signature", http.StatusUnauthorized) return } handleEvent(payload) w.WriteHeader(http.StatusOK) }) ``` > **Tip** > > Keep the signing secret in an environment variable or a secrets manager, never in a file you commit. ### The algorithm Each signature is an HMAC over the message id, the timestamp and the body, joined by dots: ```text base64(HMAC-SHA256(base64_decode(secret), "{webhook-id}.{webhook-timestamp}.{body}")) ``` > **Note** > > Every signing secret starts with `whsec_`. The SDK verifiers strip this prefix before they base64-decode the secret bytes, so pass the value exactly as it was issued. ### Timestamp tolerance By default, the verifiers reject messages with timestamps more than **5 minutes** from the current time. This prevents replay attacks. You can customize the tolerance: **Python** ```python payload = verifier.verify(body, headers, tolerance_seconds=600) ``` **TypeScript** ```typescript const payload = verifier.verify(body, headers, { toleranceSeconds: 600 }); ``` **Go** ```go payload, err := verifier.VerifyWithTolerance(r.Header, body, 10*time.Minute) ``` A wider tolerance widens the window in which a captured request can be replayed. ## Retries Svix retries failed deliveries with exponential backoff. A delivery fails if your endpoint: * Returns a non-2xx HTTP status code * Does not respond within the timeout * Is unreachable > **Note** > > A `2xx` is what tells LevelFour the delivery worked. Return it as soon as the signature checks out and the event is stored, then do the heavy processing asynchronously. > **Warning** > > A retry replays the same event, so one `webhook-id` can reach you more than once. Implement idempotent handlers using the `webhook-id` header for deduplication. ## Next * [SDKs](/sdks) has the Python, TypeScript and Go clients and their constructor options * [Webhooks in the API reference](/api-reference/webhooks) has the endpoint schemas and status codes * [Slack integration](/guides/slack-integration) builds a receiver that verifies signatures and posts to a channel, in Python and TypeScript * [Google Chat integration](/guides/google-chat-integration) does the same for a Google Chat space --- # Error Handling Source: https://docs.levelfour.ai/error-handling.md Every LevelFour SDK maps an HTTP status code to its own typed error class, and every one of those classes inherits from a base class you can catch when the specific type does not matter. Some failures never reach your handler at all, because the SDK retries them first. ## Error types | HTTP Status | Error Code | Python | TypeScript | Go | | ----------- | ----------------------- | --------------------- | --------------------- | --------------------------- | | 400 | `BAD_REQUEST` | `BadRequestError` | `BadRequestError` | `*BadRequestError` | | 401 | `AUTHENTICATION_ERROR` | `AuthenticationError` | `AuthenticationError` | `*UnauthorizedError` | | 403 | `AUTHORIZATION_ERROR` | `AuthorizationError` | `AuthorizationError` | `*ForbiddenError` | | 404 | `NOT_FOUND` | `NotFoundError` | `NotFoundError` | `*NotFoundError` | | 409 | `CONFLICT` | `ConflictError` | `ConflictError` | `*ConflictError` | | 422 | `VALIDATION_ERROR` | `ValidationError` | `ValidationError` | `*UnprocessableEntityError` | | 429 | `RATE_LIMIT_EXCEEDED` | `RateLimitError` | `RateLimitError` | `*TooManyRequestsError` | | 500 | `INTERNAL_SERVER_ERROR` | `InternalServerError` | `InternalServerError` | `*InternalServerError` | Each SDK's base class: * Python: `LevelFourError` * TypeScript: `LevelFourError` * Go: `*core.APIError` (use `errors.As` to match specific types) Python and TypeScript add two more, for failures that carry no HTTP status: * Python: `LevelFourConnectionError`, `LevelFourTimeoutError` * TypeScript: `LevelFourConnectionError`, `LevelFourTimeoutError` > **Warning** > > In Python `status_code` is `0` on both, so a branch keyed on the status code will not catch a connection failure or a timeout. Catch the class by name. ## Catching errors **Python** ```python from levelfour import ( LevelFour, LevelFourError, AuthenticationError, NotFoundError, RateLimitError, ValidationError, ) client = LevelFour() try: detail = client.recommendations.get("rec_123") except NotFoundError as e: print(f"Not found: {e.message}") print(f"Status: {e.status_code}") print(f"Code: {e.code}") except RateLimitError as e: print(f"Rate limited. Retry after: {e.retry_after}") except ValidationError as e: print(f"Validation errors: {e.details}") except AuthenticationError as e: print(f"Auth failed: {e.message}") except LevelFourError as e: print(f"API error {e.status_code}: {e.message}") ``` **TypeScript** ```typescript import { LevelFourClient, LevelFourError, NotFoundError, RateLimitError, ValidationError, AuthenticationError, } from "levelfour"; const client = new LevelFourClient(); try { await client.recommendations.get({ recommendation_id: "rec_123" }); } catch (err) { if (err instanceof NotFoundError) { console.log("Not found:", err.message); console.log("Status:", err.statusCode); } else if (err instanceof RateLimitError) { console.log("Rate limited"); } else if (err instanceof ValidationError) { console.log("Validation:", err.body); } else if (err instanceof AuthenticationError) { console.log("Auth failed:", err.message); } else if (err instanceof LevelFourError) { console.log(`API error ${err.statusCode}: ${err.message}`); } } ``` **Go** ```go import ( "errors" "fmt" "github.com/LevelFourAI/levelfour-go/levelfour" ) detail, err := client.Recommendations.Get(ctx, "rec_123") if err != nil { var notFoundErr *levelfour.NotFoundError var rateLimitErr *levelfour.TooManyRequestsError var badReqErr *levelfour.BadRequestError var unprocessableErr *levelfour.UnprocessableEntityError switch { case errors.As(err, ¬FoundErr): fmt.Printf("Not found: %v\n", notFoundErr.Body) case errors.As(err, &rateLimitErr): fmt.Printf("Rate limited: %v\n", rateLimitErr.Body) case errors.As(err, &badReqErr): fmt.Printf("Bad request: %v\n", badReqErr.Body) case errors.As(err, &unprocessableErr): fmt.Printf("Validation: %v\n", unprocessableErr.Body) default: fmt.Printf("Error: %v\n", err) } } ``` > **Warning** > > Order the branches specific first, base last. In Python and TypeScript a branch on `LevelFourError` matches every class in the table above, so a specific branch below it never runs and you never read the fields that class adds. ## Error properties **Python** All exceptions inherit from `LevelFourError`: | Property | Type | Description | | ------------- | ---------------------- | -------------------------------------------------- | | `status_code` | `int` | HTTP status code (0 for connection/timeout errors) | | `code` | `str` | Error code string | | `message` | `str` | Human-readable error message | | `details` | `dict \| list \| None` | Additional error context | `RateLimitError` adds: | Property | Type | Description | | ------------- | ------------- | ------------------------------ | | `retry_after` | `str \| None` | Seconds until retry is allowed | **TypeScript** All error classes extend `LevelFourError`: | Property | Type | Description | | ------------- | -------------------------- | -------------------------- | | `statusCode` | `number \| undefined` | HTTP status code | | `body` | `unknown` | Parsed error response body | | `rawResponse` | `RawResponse \| undefined` | Raw HTTP response | | `message` | `string` | Error message | **Go** All error types embed `*core.APIError`: | Property | Type | Description | | ------------ | ----- | --------------------------------------------- | | `StatusCode` | `int` | HTTP status code | | `Body` | typed | Parsed error response (type varies per error) | The `Body` field is typed per error: `BadRequest`, `AuthenticationError`, `AuthorizationError`, `NotFound`, `Conflict`, `*HTTPValidationError`, `RateLimitError`, `InternalServer`. ## Retry behavior Each SDK retries a failed request on its own, with exponential backoff. The default is two retries. ### Retryable errors | Condition | Retried | | ------------------------- | ------- | | Network errors / timeouts | Yes | | 408 Request Timeout | Yes | | 409 Conflict | Yes | | 429 Too Many Requests | Yes | | 5xx Server Errors | Yes | | 400 Bad Request | No | | 401 Unauthorized | No | | 403 Forbidden | No | | 404 Not Found | No | | 422 Validation Error | No | ### Configuring retries **Python** ```python # five retries client = LevelFour(max_retries=5) # no retries at all client = LevelFour(max_retries=0) ``` **TypeScript** ```typescript // five retries const client = new LevelFourClient({ maxRetries: 5 }); // no retries at all const client = new LevelFourClient({ maxRetries: 0 }); ``` **Go** ```go // five retries patient, _ := levelfour.NewClient("", levelfour.WithMaxRetries(5)) // no retries at all impatient, _ := levelfour.NewClient("", levelfour.WithNoRetries()) ``` > **Tip** > > Turn retries off while you are debugging. A failure comes back at once instead of after two backoffs. ### Per-request override **Python** ```python summary = client.recommendations.get_savings_by_provider( request_options={"max_retries": 5}, ) ``` **TypeScript** ```typescript const summary = await client.recommendations.getSavingsByProvider({ maxRetries: 5, }); ``` **Go** ```go summary, err := client.Recommendations.GetSavingsByProvider(ctx, option.WithMaxAttempts(5), ) ``` Retries are one of several per-request options. Each SDK page has the full set: [Python](/sdks/python#request-options), [TypeScript](/sdks/typescript#request-options), [Go](/sdks/go#request-options). ## Rate limits Each API key can make 600 requests a minute. Past that, the API answers `429` with the code `RATE_LIMIT_EXCEEDED`, and the SDKs back off and retry it as the [retryable errors](#retryable-errors) table shows. A batch job that walks many pages stays under the limit by paging sequentially rather than in parallel. ## Idempotent writes Send an `Idempotency-Key` header on a `POST`, `PATCH` or `PUT` to make retrying it safe. * The key must be a version 4 UUID. Anything else is a `422`. * A successful response is kept for 24 hours. The same key from the same API key inside that window gets the stored response back, with an `Idempotent-Replayed: true` header, and the write does not run again. * A failed request, `4xx` or `5xx`, is not kept, so retrying it with the same key runs it. * A request whose key is still in flight gets `409`. Wait for the first one to finish. * Keys belong to the caller that sent them. Two API keys using the same value do not collide. ```bash curl -s -X POST "https://api.levelfour.ai/api/v1/webhooks/endpoints" \ -H "Authorization: Bearer $LEVELFOUR_API_KEY" \ -H "Idempotency-Key: $(uuidgen | tr 'A-Z' 'a-z')" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/webhook", "event_types": ["optimization.completed"]}' ``` Generate a new key for each distinct write, and reuse it only when retrying that same write. ## Next * [SDKs](/sdks) compares the three clients and their constructor options, including the retry default * [API Reference](/api-reference#response-format) is the JSON envelope these error codes arrive in over REST * [Pagination](/pagination) is the other cross-SDK behavior, with auto-paginating iterators --- # Pagination Source: https://docs.levelfour.ai/pagination.md Every list endpoint returns one page of items plus the metadata to find the next. The SDKs will walk it for you, hand you one page at a time, or stay out of the way while you drive `page` and `page_size` yourself. | Approach | Reach for it when | | --------------------------------------------- | ------------------------------------------------------------- | | [Auto-pagination](#auto-pagination) | You want every result and would rather not think about pages | | [Collect all items](#collect-all-items) | You want every result in one list and the result set is small | | [Page-level iteration](#page-level-iteration) | You process results a batch at a time | | [Manual pagination](#manual-pagination) | You decide which page is fetched and when | ## Response format Paginated endpoints are offset-based. You ask for a page number and a page size, and the response reports where that page sits in the full result set. > **Note** > > Page numbers are 1-indexed. The first page is `page=1`. ```json { "success": true, "data": { "items": [], "pagination": { "total_items": 142, "total_pages": 15, "current_page": 1, "page_size": 10, "has_next": true, "has_previous": false } }, "timestamp": "2025-10-15T10:00:00Z" } ``` `success`, `data` and `timestamp` are the standard envelope on every LevelFour response, described in the [API reference](/api-reference#response-format). ## Auto-pagination The SDK fetches the next page when you reach the end of the current one, so your loop reads as though the whole result set were already in memory. **Python** ```python for rec in client.recommendations.list(page_size=50): print(rec.recommendation_id) ``` **TypeScript** ```typescript for await (const rec of await client.recommendations.list({ page_size: 50 })) { console.log(rec.recommendation_id); } ``` **Go** ```go page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ PageSize: levelfour.Int(50), }) if err != nil { log.Fatal(err) } iter := page.Iterator() for iter.Next(ctx) { rec := iter.Current() fmt.Println(rec.RecommendationID) } if err := iter.Err(); err != nil { log.Fatal(err) } ``` ### Async (Python) The async client takes the same call. Await it once, then iterate with `async for`. ```python async for rec in await async_client.recommendations.list(page_size=50): print(rec.recommendation_id) ``` ### Collect all items Fetch every page and return all items as a single collection. > **Warning** > > Collecting walks every page before it returns anything, and holds the whole result set in memory at once. On a large result set, iterate instead. **Python** ```python all_recs = list(client.recommendations.list(page_size=100)) ``` **TypeScript** ```typescript import { collectAll } from "levelfour"; const allRecs = await collectAll(await client.recommendations.list()); ``` **Go** ```go page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ PageSize: levelfour.Int(100), }) if err != nil { log.Fatal(err) } allRecs, err := levelfour.CollectAll(ctx, page) if err != nil { log.Fatal(err) } ``` ## Page-level iteration Each turn of the loop hands you a whole page rather than a single item. **Python** ```python for page in client.recommendations.list(page_size=50).iter_pages(): print(f"Processing {len(page.items)} items") for item in page.items: process(item) ``` **TypeScript** ```typescript const page = await client.recommendations.list({ page: 1, page_size: 50 }); console.log(page.data); while (page.hasNextPage()) { await page.getNextPage(); console.log(page.data); } ``` **Go** ```go page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ PageSize: levelfour.Int(50), }) if err != nil { log.Fatal(err) } for { for _, rec := range page.Results { fmt.Println(rec.RecommendationID) } nextPage, err := page.GetNextPage(ctx) if errors.Is(err, levelfour.ErrNoPages) { break } if err != nil { log.Fatal(err) } page = nextPage } ``` > **Note** > > The Go client signals a finished walk by returning `levelfour.ErrNoPages` from `GetNextPage`. Match it with `errors.Is` before treating the error as a failure. ### Async pages (Python) `iter_pages()` works the same way on the async client. ```python async for page in (await async_client.recommendations.list(page_size=50)).iter_pages(): print(f"Processing {len(page.items)} items") ``` ## Manual pagination Pass `page` and `page_size` directly and handle navigation yourself. **Python** ```python page = client.recommendations.list(page=1, page_size=50) while page: for item in page.items: process(item) page = page.next_page() ``` **TypeScript** ```typescript const page = await client.recommendations.list({ page: 1, page_size: 50 }); console.log(page.data); if (page.hasNextPage()) { await page.getNextPage(); } ``` **Go** ```go page, err := client.Recommendations.List(ctx, &levelfour.ListRecommendationsRequest{ Page: levelfour.Int(1), PageSize: levelfour.Int(50), }) if err != nil { log.Fatal(err) } nextPage, err := page.GetNextPage(ctx) if errors.Is(err, levelfour.ErrNoPages) { fmt.Println("No more pages") } ``` ## Paginated endpoints Every method below returns a paginated result. The [resource pages](/resources) carry each one's parameters and response shape. | What it lists | Python | TypeScript | Go | | ---------------------------------- | --------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------ | | Recommendations | `recommendations.list()` | `recommendations.list()` | `Recommendations.List()` | | Recommendations for one provider | `recommendations.list_by_provider()` | `recommendations.listByProvider()` | `Recommendations.ListByProvider()` | | Potential savings for one provider | `recommendations.list_provider_potential_savings()` | `recommendations.listProviderPotentialSavings()` | `Recommendations.ListProviderPotentialSavings()` | | Realized savings | `recommendations.audit.list()` | `recommendations.audit.list()` | `Recommendations.Audit.List()` | | Realized savings for one provider | `recommendations.audit.list_by_provider()` | `recommendations.audit.listByProvider()` | `Recommendations.Audit.ListByProvider()` | | Cost breakdown | `costs.list()` | `costs.list()` | `Costs.List()` | | Cost breakdown for one provider | `costs.list_by_provider()` | `costs.listByProvider()` | `Costs.ListByProvider()` | > **Note** > > There is no `providers` namespace behind these. `client.providers` lists your connected providers and nothing else. Everything scoped to a single provider lives on `recommendations` or `costs` and takes a `provider_id`. ## Defaults These are the values a request falls back to when you leave the parameter out. `page` is always 1. The page size is not the same everywhere. The two savings lists and the recommendations list are sized for scanning a backlog; the breakdowns return one row per resource and are capped tighter. | Endpoint | `page_size` default | Maximum | | --------------------------------------------------- | ------------------- | ------- | | `recommendations.list()` | 20 | 200 | | `recommendations.list_by_provider()` | 20 | 200 | | `recommendations.list_provider_potential_savings()` | 20 | 200 | | `recommendations.audit.list()` | 10 | 100 | | `recommendations.audit.list_by_provider()` | 10 | 100 | | `costs.list()` | 10 | 100 | | `costs.list_by_provider()` | 10 | 100 | > **Warning** > > Asking for more than the maximum is a `422`, not a silent clamp. The auto-paginating iterators above sidestep the whole question, because they carry the page size the server gave them. ## Next * [Error handling](/error-handling) maps each status code to its typed class and lists what the SDKs retry before you see the failure * [Resources](/resources) documents every method, paginated or not, in all three SDKs * [Python](/sdks/python), [TypeScript](/sdks/typescript) and [Go](/sdks/go) name the pager type each client returns