Skip to content

Examples

All examples read the address and the token from environment variables, so no secret ends up in the script:

Terminal window
$env:IR_URL = 'https://reports.example.com'
$env:IR_TOKEN = 'ir1.xxxx.xxxx'

Report ids, column keys and filter keys come from the report reference or from GET /ir-api/v1/reports.

Written and tested for PowerShell 7.

Terminal window
$base = "$env:IR_URL/ir-api/v1"
$headers = @{ Authorization = "Bearer $env:IR_TOKEN" }
# One page of a report; waits while an on-demand report is still loading.
function Get-IrReport {
param([string] $Id, [string] $Query = 'size=1000', [int] $MaxTries = 20)
for ($try = 1; $try -le $MaxTries; $try++) {
$result = Invoke-RestMethod "$base/reports/$($Id)?$Query&timeout=60" -Headers $headers
if ($result.state -ne 'Loading' -and -not ($result.state -eq 'NotLoaded' -and $result.isRunning)) { return $result }
Start-Sleep -Seconds 15
}
throw "Report $Id is still loading after $MaxTries tries."
}
# Every row of a report, page by page; optional named filters, e.g. -Filter 'cloud,unmanaged'.
function Get-IrAllRows {
param([string] $Id, [string] $Filter = '')
$page = 1
do {
$query = "size=1000&page=$page" + $(if ($Filter) { "&filter=$Filter" } else { '' })
$result = Get-IrReport -Id $Id -Query $query
$result.rows
$page++
} while (($page - 1) * 1000 -lt $result.filteredRows)
}
Get-IrAllRows -Id 'entra.users.all' | Export-Csv -Path ./users.csv -NoTypeInformation

Needs the Read and refresh grant for the report’s area. Invoke-RestMethod throws on an error status; the reason is in the JSON body:

Terminal window
try {
$started = Invoke-RestMethod "$base/reports/entra.users.guests/refresh" -Method Post -Headers $headers
"Refresh started: $($started.state)"
} catch {
"Refused: $($_.Exception.Response.StatusCode.value__) $($_.ErrorDetails.Message)"
}

PowerShell turns loadedUtc into a date in your local time zone. Compare it with (Get-Date) to check how fresh the data is.

Uses the requests package (pip install requests).

import os
import time
import requests
BASE = os.environ["IR_URL"].rstrip("/") + "/ir-api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["IR_TOKEN"]
def get_report(report_id, max_tries=20, **query):
"""One page of a report; waits while an on-demand report is still loading."""
query.setdefault("size", 1000)
query["timeout"] = 60
for _ in range(max_tries):
response = session.get(f"{BASE}/reports/{report_id}", params=query, timeout=90)
response.raise_for_status()
if response.status_code != 202:
return response.json()
time.sleep(15)
raise TimeoutError(f"{report_id} is still loading")
def all_rows(report_id, **query):
"""Every row of a report, page by page."""
page = 1
while True:
body = get_report(report_id, page=page, size=1000, **query)
yield from body["rows"]
if page * 1000 >= body["filteredRows"]:
return
page += 1
report = get_report("entra.users.guests", size=100)
print(report["state"], report["loadedUtc"], report["totalRows"])
users = list(all_rows("entra.users.all"))
print(len(users), "users")

Pass filters and report parameters as keyword arguments, for example get_report("entra.devices.all", filter="cloud,unmanaged") or get_report("intune.devices.stale", days=90).

Many reports are findings lists: every row has a Severity of critical, serious, warning, good or neutral. A small script can open a ticket for every new critical or serious row. It reuses session and get_report from the Python example above. Replace open_ticket with a call to your ticket system, and keep the keys of tickets already opened between runs (a file, or a search in the ticket system).

# Every report your client may read, and which of them are findings lists.
reports = session.get(f"{BASE}/reports", timeout=60).json()["reports"]
titles = {r["id"]: r["title"] for r in reports}
findings_reports = [r["id"] for r in reports if r["kind"] == "findings"]
# Watch the reports you care about rather than all of them: each one is read from Microsoft 365 when it isn't cached.
WATCH = ["entra.security.app-consent-posture", "intune.service.tenant-hygiene", "entra.security.ca-policy-gaps"]
seen = set() # keys of tickets you already opened - load them from a file or your ticket system
def open_ticket(title, text):
print("TICKET:", title)
for report_id in WATCH:
body = get_report(report_id)
if body["state"] not in ("Ok", "Empty"):
print(f"{report_id}: {body['state']} {body['error']}")
continue
for row in body["rows"]:
if row.get("Severity") not in ("critical", "serious"):
continue
values = [str(v) for k, v in row.items() if k != "Severity" and v not in (None, "")]
key = report_id + "|" + "|".join(values)
if key in seen:
continue
seen.add(key)
open_ticket(f"[{row['Severity']}] {titles[report_id]}: {' - '.join(values[:2])}",
"\n".join(f"{k}: {v}" for k, v in row.items()))

Findings lists name their columns differently (for example Subject and Finding, or Setting and Value), so the script builds the ticket from all values of the row. Run it once a day, after the data has been refreshed. GET /ir-api/v1/status shows loadedUtc for every report.

Power Query reads the API with Web.Contents. In Excel: Data > Get Data > From Other Sources > Blank Query, then Advanced Editor. In Power BI Desktop: Get Data > Blank Query.

let
Token = "ir1.xxxx.xxxx",
Answer = Json.Document(
Web.Contents(
"https://reports.example.com/ir-api/v1",
[
RelativePath = "reports/entra.users.guests",
Query = [size = "1000", timeout = "60"],
Headers = [Authorization = "Bearer " & Token]
]
)
),
Rows = Answer[rows],
Table = Table.FromRecords(Rows)
in
Table
  • When Power Query asks how to connect, choose Anonymous. The token travels in the header, not as a Power Query credential.
  • Keep the base address fixed and the report in RelativePath. Power BI’s scheduled refresh needs a static base address.
  • A query reads one page of up to 1000 rows. For larger reports, add page = "2" and so on, or use a script that writes a CSV.
  • If the report is still loading, the answer has no rows yet. Refresh the query a little later.
  • The token sits in the query text. Anyone who can open the workbook or report file can read it. Use a client with the Reader or AI assistant preset limited to the areas you need, and an expiry date.

A scheduled flow that reports new findings, in outline:

  1. Recurrence: for example daily at 07:00.
  2. HTTP: method GET, URI https://reports.example.com/ir-api/v1/reports/<report id>?size=1000&timeout=60, header Authorization = Bearer <token>. Keep the token in Azure Key Vault or a secure input rather than in the flow.
  3. Parse JSON on the body, with a schema generated from a sample answer.
  4. Condition: if the status code is 202, wait (Delay, 1 minute) and repeat the HTTP call.
  5. Filter array on rows where Severity is critical or serious.
  6. Apply to each: post to Teams, send an email or create a ticket.

The HTTP action in Power Automate needs a premium licence. Your IntensiveReports address must be reachable from Microsoft’s cloud, or through an on-premises data gateway.