Using the API
The API has five endpoints under /ir-api/v1. The REST API reference lists every field.
| Call | What it does |
|---|---|
GET /reports | The 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}/refresh | Load a report again now (Refresh grant) |
POST /reports/{id}/scan | Start a scan report (Run scan grant) |
GET /status | State and freshness of every report your client may read |
GET /findings | The problems the dashboard counts, worst first, with the report that lists each |
GET /openapi.json | The OpenAPI description, no token needed |
Where the data comes from
Section titled “Where the data comes from”IntensiveReports never reads Microsoft 365 just because you called the API. Each report has a mode, shown as mode in GET /reports:
| Mode | Behaviour | What you see |
|---|---|---|
Derived | Built 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. |
OnDemand | Read 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. |
Manual | A 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.
Reports that are still loading
Section titled “Reports that are still loading”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"(orNotLoaded) andisRunning: 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.
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."}States
Section titled “States”state | Meaning | Rows |
|---|---|---|
Ok | Loaded. | Yes |
Empty | Loaded, nothing found. For many reports that is the good news (“no expired secrets”). | None |
Loading | Being read now. Call again (202). | None, or the previous result while it reloads |
NotLoaded | Not asked for since the server started. A GET starts the load (202), except for a scan report, which waits for Run scan (200). | None |
Failed | Loading failed; error says why, for example a missing permission or licence. | The last good result, if there was one |
NotApplicable | The report doesn’t apply to your tenant; notApplicableReason says why. | None |
SwitchedOff | An 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.
Paging
Section titled “Paging”Rows come in pages:
| Parameter | Default | Limit |
|---|---|---|
size | 100 | 1000 |
page | 1 | - |
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 += 1size=0 isn’t allowed (it is raised to 1). For the summary only, read metrics and ignore the rows, or use GET /status.
Filters
Section titled “Filters”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
filtersfield 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 /reportsmarks those withonInPage: true. To get the same rows as the page, pass them explicitly.
Parameters
Section titled “Parameters”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.comGET /ir-api/v1/reports/intune.devices.stale?days=90A missing or invalid required value answers 400, for example This report needs ?upn=: User principal name (upn) is missing.
Metrics, charts and notes
Section titled “Metrics, charts and notes”metrics: the report’s summary tiles, each withlabel,value(text, for example"1 (2%)"), an optionalseverityandhint. They often answer the question without reading any rows: Admins without MFA = 4.charts: the data behind the report’s charts: atitleandpoints(label,value).notes: remarks shown above the table, for example which data couldn’t be checked.
Findings summary
Section titled “Findings summary”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.
Refresh and scan
Section titled “Refresh and scan”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
Derivedreport it refreshes the data the report is built from. - Scan starts a
Manualreport. It needs the Read, refresh and run scans grant. A scan can take minutes; follow it withGET /reports/{id}(isRunning,progress). - Both answer
202at once, with the current state and no rows. They never wait for the load. POST /scanon a report that isn’t a scan answers409; use refresh.POST /refreshon a scan report needs the Run scan grant.- Some web servers refuse a
POSTwithout a body. Send an empty body (Content-Length: 0). PowerShell’sInvoke-RestMethod -Method Postdoes 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.
Status codes
Section titled “Status codes”Every refusal has a JSON body with one sentence: { "error": "..." }.
| Status | When |
|---|---|
200 | The answer, also for states Empty, Failed, NotApplicable, SwitchedOff and a scan report that hasn’t run yet |
202 | GET: the report is still loading, call again. POST: the refresh or scan was started. |
400 | A report parameter is missing or invalid. |
401 | No token, or a token that is unknown, expired or switched off. See Authentication. |
403 | Your client may read the report but not refresh or scan it: This client may not refresh in EntraId. |
404 | No 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. |
409 | Scan on a report that isn’t a scan, or refresh or scan while the report’s data is switched off in Settings. |
429 | Rate limit reached. Wait the number of seconds in the Retry-After header. |
Rate limits
Section titled “Rate limits”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.