Examples
All examples read the address and the token from environment variables, so no secret ends up in the script:
$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.
PowerShell
Section titled “PowerShell”Written and tested for PowerShell 7.
Read a report and export it to CSV
Section titled “Read a report and export it to CSV”$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 -NoTypeInformationRefresh a report
Section titled “Refresh a report”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:
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.
Python
Section titled “Python”Uses the requests package (pip install requests).
import osimport 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).
Findings to tickets
Section titled “Findings to tickets”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.
Excel and Power BI (Power Query)
Section titled “Excel and Power BI (Power Query)”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.
Power Automate and Logic Apps
Section titled “Power Automate and Logic Apps”A scheduled flow that reports new findings, in outline:
- Recurrence: for example daily at 07:00.
- HTTP: method
GET, URIhttps://reports.example.com/ir-api/v1/reports/<report id>?size=1000&timeout=60, headerAuthorization=Bearer <token>. Keep the token in Azure Key Vault or a secure input rather than in the flow. - Parse JSON on the body, with a schema generated from a sample answer.
- Condition: if the status code is
202, wait (Delay, 1 minute) and repeat the HTTP call. - Filter array on
rowswhereSeverityiscriticalorserious. - 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.