Skip to content

Using the API

The API has five endpoints under /ir-api/v1. The REST API reference lists every field.

CallWhat it does
GET /reportsThe reports your client may read: id, title, the question each answers, product area, mode, kind (findings or table), columns, filters, parameters, related reports and the actions you may take
GET /reports/{id}One report: state, freshness, metrics, charts, notes and one page of rows
POST /reports/{id}/refreshLoad a report again now (Refresh grant)
POST /reports/{id}/scanStart a scan report (Run scan grant)
GET /statusState and freshness of every report your client may read
GET /findingsThe problems the dashboard counts, worst first, with the report that lists each
GET /openapi.jsonThe OpenAPI description, no token needed

IntensiveReports never reads Microsoft 365 just because you called the API. Each report has a mode, shown as mode in GET /reports:

ModeBehaviourWhat you see
DerivedBuilt from tenant data that IntensiveReports keeps in memory and refreshes in the background.Answers at once. Just after the server starts, it waits up to timeout seconds for the data to arrive.
OnDemandRead from Microsoft 365 the first time someone asks (in the app, the API or MCP), then kept for the report’s lifetime: 1 hour by default (Settings > Data & cache), unless the report sets its own (its page in the report reference says so). Reports with usesPowerShell: true use Exchange Online or SharePoint PowerShell and take longer.The first call may answer 202 with state Loading. Later calls get the kept result at once.
ManualA scan: long-running and expensive, so it runs only when someone presses Run scan in the app or calls POST /reports/{id}/scan. The last result is kept.State NotLoaded until the first scan has run. A GET never starts a scan.

Each report’s page in the report reference shows its mode under Data.

GET /reports/{id} waits up to timeout seconds for a report that is loading (default 20, at most 60). If the data isn’t there by then, the load keeps running on the server and you get:

  • status 202 Accepted,
  • state: "Loading" (or NotLoaded) and isRunning: true,
  • no rows.

Call again after a few seconds. The second call gets the result as soon as the load has finished. Don’t start a refresh to “speed it up”: a load is already running.

Terminal window
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."
}
stateMeaningRows
OkLoaded.Yes
EmptyLoaded, nothing found. For many reports that is the good news (“no expired secrets”).None
LoadingBeing read now. Call again (202).None, or the previous result while it reloads
NotLoadedNot asked for since the server started. A GET starts the load (202), except for a scan report, which waits for Run scan (200).None
FailedLoading failed; error says why, for example a missing permission or licence.The last good result, if there was one
NotApplicableThe report doesn’t apply to your tenant; notApplicableReason says why.None
SwitchedOffAn administrator switched the data this report needs off in Settings > Data & cache. Nothing is fetched until it is switched on again.None

partialWarning is set when the result is usable but part of its data couldn’t be read. loadedUtc is when the data was read from Microsoft 365. GET /status returns state, loadedUtc, isRunning, error and the row count of every report you may read, without rows. It’s a cheap way to check freshness or find failed reports.

Rows come in pages:

ParameterDefaultLimit
size1001000
page1-

filteredRows is the number of rows after filters. Read pages until page × size reaches filteredRows:

def all_rows(report_id, **query):
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

size=0 isn’t allowed (it is raised to 1). For the summary only, read metrics and ignore the rows, or use GET /status.

Many reports have named filters, such as Show only unmanaged devices (key unmanaged). GET /reports and each report’s page list them.

GET /ir-api/v1/reports/entra.devices.all?filter=cloud,unmanaged
  • Several filters are separated by commas, and all of them must match.
  • Unknown filter keys are ignored. The answer’s filters field shows which filters were applied.
  • The API applies only the filters you ask for. Some report pages switch certain filters on when they open; GET /reports marks those with onInPage: true. To get the same rows as the page, pass them explicitly.

A few reports need a value, for example the user to look at. GET /reports lists them under parameters (key, description, example, whether it’s required, default, allowed values). Pass them as query parameters:

GET /ir-api/v1/reports/entra.governance.user-access-360?upn=adele@example.com
GET /ir-api/v1/reports/intune.devices.stale?days=90

A missing or invalid required value answers 400, for example This report needs ?upn=: User principal name (upn) is missing.

  • metrics: the report’s summary tiles, each with label, value (text, for example "1 (2%)"), an optional severity and hint. They often answer the question without reading any rows: Admins without MFA = 4.
  • charts: the data behind the report’s charts: a title and points (label, value).
  • notes: remarks shown above the table, for example which data couldn’t be checked.

GET /findings returns what the dashboard’s Needs attention list counts, for the reports your client may read: severity (Critical, Serious or Warning), count, title and reportId, the report that lists the objects. It reads only data the app already has, so it answers at once. note is set when a check couldn’t run and some counts may be missing.

{
"count": 2,
"findings": [
{ "severity": "Critical", "count": 4, "title": "Disabled users who still hold a licence", "reportId": "entra.licensing.licence-reclamation", "area": "Licensing" },
{ "severity": "Serious", "count": 2, "title": "Privileged role members without phishing-resistant enforcement", "reportId": "entra.security.admin-phishing-resistant-mfa", "area": "EntraId" }
],
"note": null
}

To get the objects behind a finding, read its report: GET /reports/{reportId}. In GET /reports, kind tells a findings list (every row has a Severity) from an inventory table.

Terminal window
curl -X POST -H "Authorization: Bearer $IR_TOKEN" -H "Content-Length: 0" \
https://reports.example.com/ir-api/v1/reports/entra.users.guests/refresh
  • Refresh loads a report again from Microsoft 365 now. It needs the Read and refresh grant for the report’s area. For a Derived report it refreshes the data the report is built from.
  • Scan starts a Manual report. It needs the Read, refresh and run scans grant. A scan can take minutes; follow it with GET /reports/{id} (isRunning, progress).
  • Both answer 202 at once, with the current state and no rows. They never wait for the load.
  • POST /scan on a report that isn’t a scan answers 409; use refresh. POST /refresh on a scan report needs the Run scan grant.
  • Some web servers refuse a POST without a body. Send an empty body (Content-Length: 0). PowerShell’s Invoke-RestMethod -Method Post does this by itself; with curl add -d '' or the header above.

Refreshing reads Microsoft 365 again and counts against Microsoft’s throttling limits for your tenant. Refresh only when you need newer data than loadedUtc.

Every refusal has a JSON body with one sentence: { "error": "..." }.

StatusWhen
200The answer, also for states Empty, Failed, NotApplicable, SwitchedOff and a scan report that hasn’t run yet
202GET: the report is still loading, call again. POST: the refresh or scan was started.
400A report parameter is missing or invalid.
401No token, or a token that is unknown, expired or switched off. See Authentication.
403Your client may read the report but not refresh or scan it: This client may not refresh in EntraId.
404No such report.: the id doesn’t exist or your client may not read it. To protect report names, both look the same. While the API is switched off, every address answers 404.
409Scan on a report that isn’t a scan, or refresh or scan while the report’s data is switched off in Settings.
429Rate limit reached. Wait the number of seconds in the Retry-After header.

The API and the MCP server share one limit:

  • 120 requests a minute per client,
  • 20 requests a minute per IP address for calls without a valid token.

The administrator can change both (IntensiveReportsConfig:Api:RequestsPerMinute and AnonymousRequestsPerMinute). A refused call answers 429 with Retry-After. Ask for size=1000 instead of many small pages, and use GET /status instead of reading every report to check freshness.

Every call is written to the audit log on the server, refused ones included: time, client, signed-in user or app (for Entra ID clients), IP address, the request with its query, the report, the action, the status and the number of rows returned. The log is kept for 90 days. While the API is switched off, nothing is logged. See API and MCP access.