Alerts

Create, manage, and automate DoiT spend alerts programmatically. This guide shows you how to build alert payloads, choose the right metric and threshold, list and reconcile alert definitions, and avoid runtime surprises.

It is a task-oriented guide, not a field-by-field specification. For every field, parameter, and status code, use the Alerts API reference and Alerts CLI reference. For alerts in the DoiT console, see Alerts.

Permissions

  • The Cloud Analytics User permission to call the Alerts API or CLI.
  • Per-alert access to manage an existing alert: editor or owner to update, owner to delete. Grant access with Sharing or CLI Sharing.
  • List and get operations are sharing-aware: they return only alerts shared with you, unless you have the Cloud Analytics Admin permission, which sees every alert in the organization.

API and CLI Operations

Quickstart

This walkthrough creates a simple alert, confirms that it exists, and shows how to find it in your alerts list. It uses an absolute monthly spend cap because that is the easiest alert rule to understand. The sections that follow explain alert configuration in more detail, including the alert object, how DoiT evaluates alerts, and how to create alerts for scoped, per-project, percentage-change, and forecast use cases.

1. Create your first alert

This rule fires when your total monthly cost across all billing data exceeds 1000 USD. There are no scopes and no evaluateForEach, so it evaluates once over everything.

Save this body as first-alert.json:

{
  "name": "Monthly spend cap",
  "recipients": ["[email protected]"],
  "config": {
    "dataSource": "billing",
    "scopes": [],
    "metric": { "type": "basic", "value": "cost" },
    "currency": "USD",
    "timeInterval": "month",
    "condition": "value",
    "operator": "gt",
    "value": 1000
  }
}

Use the same file for the API and CLI request body:

curl -X POST "https://api.doit.com/analytics/v1/alerts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @first-alert.json
dci create-alert --body @first-alert.json

On success, the API returns 201 with the full alert object. Copy the generated id for the next step:

{
  "id": "7jyrczd6CSh3M8TuQ6Qq",
  "name": "Monthly spend cap",
  "createTime": 1717200000000,
  "updateTime": 1717200000000,
  "lastAlerted": null,
  "recipients": ["[email protected]"],
  "config": {
    "dataSource": "billing",
    "scopes": [],
    "metric": { "type": "basic", "value": "cost" },
    "currency": "USD",
    "timeInterval": "month",
    "condition": "value",
    "operator": "gt",
    "value": 1000
  }
}

2. Confirm the alert exists

Fetch the alert back and check that config.value, config.timeInterval, and recipients match what you sent:

curl "https://api.doit.com/analytics/v1/alerts/<ALERT_ID>" \
  -H "Authorization: Bearer YOUR_API_KEY"
dci get-alert <ALERT_ID>

Get returns the same alert object shape as create. Use it as the starting point before a safe update (get → edit → PATCH).

3. Find it in your list

curl -G "https://api.doit.com/analytics/v1/alerts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "filter=name:Monthly spend cap"
dci list-alerts --filter "name:Monthly spend cap"

The response is { "pageToken", "rowCount", "alerts": [ … ] }. Each entry is a full alert object. name must match exactly; it is not a substring search. See List, filter, and sort.

How alert evaluation works

DoiT evaluates each alert on a schedule and notifies its recipients when the threshold is crossed. Every evaluation runs the same four steps, and the config object maps directly onto them:

  1. Filter rows: dataSource + scopes select which billing lines count.

  2. Aggregate: metric (and currency for cost) over the timeInterval.

  3. Compare: condition, operator, and value define the threshold.

  4. Split (optional) : evaluateForEach repeats steps 2 and 3 per dimension value, for example, per project.

Create alert

To create an alert, call POST https://api.doit.com/analytics/v1/alerts or CLI create-alert with name, recipients, and a config object that defines what DoiT evaluates. See Quickstart for a full walkthrough and the create response shape.

Build config

This section walks through the four evaluation steps and the config fields for each. The JSON body is the same for API and CLI, save it as a reusable file.

1. Choose the data to watch

  1. Set config.dataSource to billing or billing-datahub (billing and DataHub data).

  2. Optionally set scopes to narrow the data to a subset of projects, services, labels, or allocation rules before any threshold is applied.

    A scope entry pairs a dimension id with a type ( fixed, label, or allocation_rule). To discover valid dimensions and values, use list-dimensions / list-dimensions and get-dimension / get-dimension.

    You can then add additional filters using operators mode ( is, contains, etc), inverse (include or exclude values) and caseInsensitive , combined with values where you add strings from your billing or DataHub data. includeNull includes or excludes rows that have no value.

    Common scope patterns:

    ...
    "scopes": [
      {
        "id": "service_description",
        "type": "fixed",
        "mode": "is",
        "values": ["Amazon Simple Storage Service"],
        "inverse": false
      }
    ]
    ...
    "scopes": [
      {
        "id": "cloud_provider",
        "type": "fixed",
        "mode": "is",
        "values": ["microsoft-azure"],
        "inverse": true
      }
    ]
    ...
    "scopes": [
      {
        "id": "environment",
        "type": "label",
        "mode": "is",
        "values": ["production", "staging"],
        "inverse": false,
        "includeNull": true
      }
    ]
    ...
    "scopes": [
      {
        "id": "allocation_rule",
        "type": "allocation_rule",
        "mode": "is",
        "values": ["allocation-rule-id"]
      }
    ]
❗️

One scope filter per alert. The API applies only the first entry in the scopes array; additional scopes are validated but ignored. Do not rely on combining multiple scopes until that is documented as supported. If additional scopes are malformed the call will fail silently. Use a single, well-chosen filter, or dataSource plus evaluateForEach to slice spend instead.

2. Aggregate data

Set config.metric, config.currency, and config.timeInterval to define what DoiT sums, in which units, and over what period. currency is only used for monetary metrics.

timeInterval (day, week, month, quarter, year) also defines the period that the condition tests against, for example the "previous period" for a percentage-change alert or the horizon for a forecast.

Common aggregation patterns:

...
"metric": { "type": "basic", "value": "cost" },
"currency": "USD",
"timeInterval": "month"
...
"metric": { "type": "basic", "value": "usage" },
"timeInterval": "day"
...
"metric": { "type": "extended", "value": "amortized cost" },
"currency": "USD",
"timeInterval": "month"
...
"metric": { "type": "custom", "value": "your-custom-metric-id" },
"currency": "USD",
"timeInterval": "week"
📘

Custom metrics are created in the DoiT console, see Create custom metric.

The basic and extended metrics are listed in Predefined metrics.

3. Set the threshold

The threshold is evaluated over the timeInterval from the previous step.

  1. Set the test type using condition (value, percentage-change, forecast) .
  2. Set the threshold using the operator (gt or lt) and value .

Common threshold patterns:

...
"condition": "value",
"operator": "gt",
"value": 5000
...
"timeInterval": "day",
"condition": "percentage-change",
"operator": "gt",
"value": 20
...
"timeInterval": "month",
"condition": "forecast",
"operator": "gt",
"value": 10000

For cost alerts with percentage-change, DoiT also compares usage. If usage is flat versus the previous period, the alert does not fire even when the percentage threshold is met. See Troubleshooting.

4. Decide whether to split the evaluation

Add evaluateForEach when the same threshold should run separately per dimension value instead of once across all matching data.

...
"evaluateForEach": "fixed:project_id"

That means “alert if any project crosses the threshold,” not “alert if all matching projects combined cross the threshold.” Omit evaluateForEach to evaluate once across all scoped rows.

Recipients and notifications

Set recipients to the list of email addresses that should receive the alert.

To route alerts into Slack or Teams:

  1. Enable email-to-channel in Slack or Teams workspace (setup on your side).
  2. Add the channel's email address (_@_.slack.com or _@_.teams.ms) to recipients on create or via a later PATCH.

Example alerts

The examples below combine all four steps into complete, ready-to-use alerts. Copy one, adjust its scope or threshold, and create it as in the Quickstart.

Fixed threshold per project

Combines an S3 scope, monthly cost cap, and per-project split. See Choose the data to watch, Set the threshold, and Split the evaluation.

{
  "name": "S3 bucket cost",
  "recipients": ["[email protected]"],
  "config": {
    "dataSource": "billing-datahub",
    "scopes": [
      {
        "id": "service_description",
        "type": "fixed",
        "mode": "is",
        "values": ["Amazon Simple Storage Service"],
        "inverse": false
      }
    ],
    "metric": { "type": "basic", "value": "cost" },
    "currency": "USD",
    "timeInterval": "month",
    "condition": "value",
    "operator": "gt",
    "value": 100,
    "evaluateForEach": "fixed:project_id"
  }
}
curl -X POST "https://api.doit.com/analytics/v1/alerts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @s3-bucket-cost-alert.json
dci create-alert --body @s3-bucket-cost-alert.json

Daily percentage-change spike

Uses the daily percentage-change pattern.

{
  "name": "Daily cost spike",
  "recipients": ["[email protected]"],
  "config": {
    "dataSource": "billing",
    "scopes": [],
    "metric": { "type": "basic", "value": "cost" },
    "currency": "USD",
    "timeInterval": "day",
    "condition": "percentage-change",
    "operator": "gt",
    "value": 20
  }
}
curl -X POST "https://api.doit.com/analytics/v1/alerts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @daily-cost-spike-alert.json
dci create-alert --body @daily-cost-spike-alert.json

Monthly forecast

Uses the monthly forecast pattern.

{
  "name": "Monthly cost forecast",
  "recipients": ["[email protected]"],
  "config": {
    "dataSource": "billing",
    "scopes": [],
    "metric": { "type": "basic", "value": "cost" },
    "currency": "USD",
    "timeInterval": "month",
    "condition": "forecast",
    "operator": "gt",
    "value": 10000
  }
}
curl -X POST "https://api.doit.com/analytics/v1/alerts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d @monthly-forecast-alert.json
dci create-alert --body @monthly-forecast-alert.json

Update alert

API PATCH https://api.doit.com/analytics/v1/alerts/\\{id} or CLI dci update-alert id merges only the fields you send. Omitted top-level fields and omitted config subfields are left unchanged. The OpenAPI schema marks some config fields as required, but PATCH applies only the subfields you include. You do not need to resend the full config to change one value.

👍

Safe update workflow

  1. Fetch the current definition with API GET https://api.doit.com/analytics/v1/alerts/{id} or CLI dci get-alert id.
  2. Edit the JSON locally.
  3. Send a PATCH with only the fields you want to change.

Example, change name and recipients:

curl -X PATCH "https://api.doit.com/analytics/v1/alerts/<ALERT_ID>" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production spend alert",
    "recipients": ["[email protected]", "[email protected]"]
  }'
cat > patch.json <<'EOF'
{
  "name": "Production spend alert",
  "recipients": ["[email protected]", "[email protected]"]
}
EOF
dci update-alert <ALERT_ID> --body @patch.json

Example, raise threshold, keep everything else:

curl -X PATCH "https://api.doit.com/analytics/v1/alerts/<ALERT_ID>" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "value": 5000
    }
  }'
cat > patch.json <<'EOF'
{
  "config": {
    "value": 5000
  }
}
EOF

dci update-alert <ALERT_ID> --body @patch.json

List and get alerts

Use API GET /analytics/v1/alerts or CLI list-alerts to discover and inventory all alert definitions that you have access to. Use APIGET /analytics/v1/alerts/{id} or CLI dci get-alert to get the full object for an alert that you have the id for.

Both operations return the same alert structure as the create operation (see Quickstart). For the list operation the alert definitions also include the alert owner , and they are wrapped in an "alerts" object ({ "pageToken", "rowCount", "alerts": [ … ] }).

You can filter the alert lists. Only name and owner are valid filter keys. You must use exact matches or 400 is returned (unknown keys). For instructions on the general Filter syntax, see Filters, and paging, see Pagination. When paging a list, keep filter, sortBy, and sortOrder the same on every request.

GoalApproach
All alerts you can accessList; omit filter. The alert with the newest createTime is listed first by default.
By ownerfilter=owner:[email protected]
By exact namefilter=name:Production spend alert
Owner and namefilter=owner:[email protected]|name:Production spend alert
Confirm or edit one alertGet by id before PATCH
Recently modifiedsortBy=updateTime, sortOrder=desc
Recently triggeredsortBy=lastAlerted, sortOrder=desc (lastAlerted is last fire time, not an event log)
Recently createdsortBy=createTime, sortOrder=desc
By metric, scope, or labelFilter client-side after list, or use labels

Example list call with filter and sort:

curl -G "https://api.doit.com/analytics/v1/alerts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "filter=owner:[email protected]" \
  --data-urlencode "sortBy=lastAlerted" \
  --data-urlencode "sortOrder=desc" \
  --data-urlencode "maxResults=50"
dci list-alerts \
  --filter "owner:[email protected]" \
  --sort-by lastAlerted \
  --sort-order desc \
  --max-results 50

Organize alerts with labels

Assign alerts to labels using API POST https://api.doit.com/analytics/v1/labels/\\{id}/assignments or CLI dci assign-objects-to-label id, with objectType:"alert". For example, you can tag alerts with labels for project, team, or status groupings.

Automate and integrate

The API and CLI are most useful when scripted. Because every operation is JSON in and JSON out, you can treat alerts the way you treat code: keep a live inventory, reconcile definitions against Git, or provision them from CI. The patterns below are common use cases, and the two recipes that follow include full Python.

Use caseApproach
Alert inventory dashboardList with sortBy=lastAlerted; page with maxResults + pageToken. See Build an alert inventory dashboard.
Alerts-as-code drift checkList accessible alerts, get each by id, diff the JSON against. See Reconcile alerts against Git.
Provision alerts as codeStore alert JSON in Git; POST / dci create-alert on merge to main, PATCH to tune config.value or recipients without recreating.
Per-environment thresholdsReuse the same config with different value or scopes per workspace. Reconcile drift with filter=owner:… or filter=name:….
Notify chat channelsAdd Slack (*@*.slack.com) or Teams (*@*.teams.ms) channel emails to recipients after enabling email-to-channel in the workspace. See Recipients.
Event-driven automationFor firing events with cost breakdown, use CloudFlow alert triggers rather than polling list/get.

Build an alert inventory dashboard

Maintain a table of the alert rules your key can access: name, owner, threshold summary, and last fired time.

  1. Call GET /analytics/v1/alerts with sortBy=lastAlerted and sortOrder=desc to surface recently fired rules first.
  2. If the response includes pageToken, repeat with the same sortBy, sortOrder, and filter until pageToken is empty.
  3. Map list fields to dashboard columns: name, owner, lastAlerted, updateTime, and selected config fields (timeInterval, condition, value, metric, first scopes entry).
  4. Call get only when a drill-down needs full recipients or complete config before an edit.

The script below does all of this. The interesting part is summarize_threshold, which turns the config object into a one-line rule like cost > 100 USD/month [all spend, per project].

"""Inventory every DoiT alert the API key can access.

Usage:
    export DOIT_API_KEY="..."
    python alert_inventory.py
"""
import os
import requests

BASE_URL = "https://api.doit.com/analytics/v1/alerts"
HEADERS = {"Authorization": f"Bearer {os.environ['DOIT_API_KEY']}"}


def list_all_alerts(filter=None, sort_by="lastAlerted", sort_order="desc"):
    """Yield every accessible alert, following pageToken to the end."""
    params = {"sortBy": sort_by, "sortOrder": sort_order, "maxResults": 50}
    if filter:
        params["filter"] = filter
    while True:
        resp = requests.get(BASE_URL, headers=HEADERS, params=params, timeout=30)
        resp.raise_for_status()
        body = resp.json()
        yield from body.get("alerts", [])
        token = body.get("pageToken")
        if not token:
            break
        params["pageToken"] = token  # keep sortBy/sortOrder/filter stable


def summarize_threshold(config):
    """Condense a config object into a single human-readable rule."""
    metric = config.get("metric", {}).get("value", "?")
    op = {"gt": ">", "lt": "<"}.get(config.get("operator"), config.get("operator"))
    value = config.get("value", "?")
    interval = config.get("timeInterval", "?")
    currency = config.get("currency", "")
    condition = config.get("condition")

    if condition == "percentage-change":
        rule = f"{metric} change {op} {value}% vs previous {interval}"
    elif condition == "forecast":
        rule = f"{metric} forecast {op} {value} {currency}/{interval}".strip()
    else:  # "value"
        rule = f"{metric} {op} {value} {currency}/{interval}".strip()

    # Only the first scope entry is applied today.
    scopes = config.get("scopes") or []
    if scopes:
        first = scopes[0]
        verb = "not in" if first.get("inverse") else "in"
        scope = f"{first.get('id')} {verb} {first.get('values')}"
    else:
        scope = "all spend"

    per = config.get("evaluateForEach")
    split = f", per {per.split(':')[-1]}" if per else ""
    return f"{rule} [{scope}{split}]"


def main():
    rows = [
        {
            "name": a.get("name", ""),
            "owner": a.get("owner", ""),
            "last_fired": str(a.get("lastAlerted") or "never"),
            "rule": summarize_threshold(a.get("config", {})),
        }
        for a in list_all_alerts()
    ]
    if not rows:
        print("No accessible alerts.")
        return

    widths = {k: max(len(k), *(len(r[k]) for r in rows)) for k in rows[0]}
    header = "  ".join(k.upper().ljust(widths[k]) for k in widths)
    print(header)
    print("-" * len(header))
    for r in rows:
        print("  ".join(r[k].ljust(widths[k]) for k in widths))
    print(f"\n{len(rows)} alert(s)")


if __name__ == "__main__":
    main()

For an ad-hoc check without writing code, the CLI surfaces the same list:

dci list-alerts \
  --sort-by lastAlerted \
  --sort-order desc \
  --max-results 50
📘

lastAlerted is only the most recent firing. A rule can fire many times, so you cannot reconstruct "everything that fired yesterday" from it. For that, use CloudFlow alert triggers.

Reconcile alerts against Git

Compare live alert definitions to JSON stored in version control (alerts-as-code):

  1. Call GET /analytics/v1/alerts without a filter (or with filter=owner:… for one team).
  2. Page through all results with maxResults and pageToken.
  3. For each id in the list, call GET /analytics/v1/alerts/{id}.
  4. Normalize JSON (for example sort recipients, ignore read-only timestamps) and diff against the matching file in your repo.
  5. On drift, PATCH the live alert or update Git after an intentional console change.

The script keys alerts by name, reports three kinds of drift, and exits non-zero so you can gate a CI job on it. Store one <name>.json per alert in a directory (the create-body shape from Common alert types).

"""Drift check: live DoiT alerts vs JSON definitions in a repo.

Usage:
    export DOIT_API_KEY="..."
    python reconcile_alerts.py ./alerts   # dir of <name>.json files
Exits 1 if any alert drifted, is live-only, or is repo-only.
"""
import os
import sys
import json
import requests

BASE_URL = "https://api.doit.com/analytics/v1/alerts"
HEADERS = {"Authorization": f"Bearer {os.environ['DOIT_API_KEY']}"}

# Server-managed fields that must never count as drift.
READ_ONLY = {"id", "owner", "createTime", "updateTime", "lastAlerted"}


def list_all_alerts(filter=None):
    params = {"maxResults": 100}
    if filter:
        params["filter"] = filter
    while True:
        resp = requests.get(BASE_URL, headers=HEADERS, params=params, timeout=30)
        resp.raise_for_status()
        body = resp.json()
        yield from body.get("alerts", [])
        token = body.get("pageToken")
        if not token:
            break
        params["pageToken"] = token


def get_alert(alert_id):
    resp = requests.get(f"{BASE_URL}/{alert_id}", headers=HEADERS, timeout=30)
    resp.raise_for_status()
    return resp.json()


def normalize(alert):
    """Canonical form so equal definitions produce identical strings."""
    cleaned = {k: v for k, v in alert.items() if k not in READ_ONLY}
    if "recipients" in cleaned:
        cleaned["recipients"] = sorted(cleaned["recipients"])
    return json.dumps(cleaned, sort_keys=True, indent=2)


def load_repo(repo_dir):
    """Map alert name -> normalized JSON for every *.json file in repo_dir."""
    repo = {}
    for fname in os.listdir(repo_dir):
        if fname.endswith(".json"):
            with open(os.path.join(repo_dir, fname)) as f:
                definition = json.load(f)
            repo[definition["name"]] = normalize(definition)
    return repo


def main(repo_dir):
    repo = load_repo(repo_dir)
    # List items may already include config; get is the authoritative source.
    live = {a["name"]: normalize(get_alert(a["id"])) for a in list_all_alerts()}

    drift = False
    for name in sorted(set(live) | set(repo)):
        if name not in repo:
            print(f"[LIVE ONLY] {name}  (in DoiT, missing from repo)")
        elif name not in live:
            print(f"[REPO ONLY] {name}  (in repo, not created in DoiT)")
        elif live[name] != repo[name]:
            print(f"[DRIFTED]   {name}")
        else:
            print(f"[IN SYNC]   {name}")
            continue
        drift = True

    sys.exit(1 if drift else 0)


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "./alerts")

To print the full diff for a drifted alert, feed both normalized strings to Python's difflib.unified_diff. The CLI covers the underlying list and get if you prefer to script in shell:

dci list-alerts --filter "owner:[email protected]" --max-results 100
dci get-alert ALERT_ID
📘

filter=name:… requires the exact alert name. Use list without a filter when reconciling the full catalog.

Troubleshooting

  • It hasn't been evaluated yet. DoiT evaluates billing data only after cloud providers publish it, so daily cost alerts in particular can lag. A freshly created alert has not necessarily run.
  • Usage was flat (percentage-change cost alerts). For cost alerts with condition: percentage-change, DoiT also checks usage. If usage didn't change versus the previous period, the alert won't fire even if cost crossed the percentage threshold, typically because a credit or discount moved cost without moving consumption.
  • A credit or discount distorted a daily comparison. Credits or discounts that start on a specific day can push a daily percentage-change alert into a false positive or suppress a real one. Widen the timeInterval if daily noise is the problem.
  • Your second scope filter was ignored. Only the first entry in scopes is applied. If you expected a two-filter intersection, the alert is watching more spend than you think. Consolidate to one filter or split with evaluateForEach.
  • You're looking at definitions, not firings. List and get return configuration, and lastAlerted is only the latest firing time. They are not an event log. For firing history and event-driven workflows, use use CloudFlow alert triggers. The returned object is an alert definitions, and not a history of alert firings. When an alert fires, DoiT updates its lastAlerted timestamp and leaves the rest of the definition unchanged.
  • The alert isn't shared with your key. List and get are sharing-aware. If an alert is missing from your list, confirm it's shared with you or that you have Cloud Analytics Admin.

For more context, including console-specific examples, see Alerts caveats.