> For the complete documentation index, see [llms.txt](https://docs.material.security/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.material.security/reference/api-v1/oauth-apps.md).

# OAuth Apps

**Overview**

The OAuth Apps API provides programmatic access to the third-party applications that hold [OAuth token grants](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) in your tenant. Google Workspace is the only provider covered today, so every app returned is a Google OAuth client. Each app exposes its Google client ID, the scopes currently granted across your accounts, the restricted-scope families those scopes fall into, brand metadata and verification status, its committed classification, any classification recommended by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps), and the standing remediation policy assigned to it.

Five endpoints make up the OAuth Apps API:

* [**List OAuth Apps**](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps) (`GET /api/v1/oauth-apps`): filter, sort, and page through OAuth apps
* [**Get OAuth App**](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id) (`GET /api/v1/oauth-apps/{id}`): retrieve a single OAuth app by its encoded ID
* [**List OAuth App Accounts**](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id-accounts) (`GET /api/v1/oauth-apps/{id}/accounts`): page the accounts that have granted one app
* [**Revoke OAuth App Account Grant**](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke) (`POST /api/v1/oauth-apps/{id}/accounts/{accountId}/revoke`): revoke one account's grant of an app
* [**Update OAuth App**](https://docs.material.security/reference/api-v1/oauth-apps#patch-api-v1-oauth-apps-id) (`PATCH /api/v1/oauth-apps/{id}`): set the classification and the remediation policy

**Filtering**

List accepts `search` (substring match over app name or client ID), `clientId` (exact match, the way to resolve a client ID from a Google Workspace audit log), `classification`, `remediation`, `hasActiveAccounts`, `verified`, `internal`, `loginOnly`, `restrictedScopeType`, and `tenant` (encoded tenant IDs; always intersected with the tenants your token can reach, so an unreachable ID matches nothing). Comma-separated values match any of the listed values within a single filter, and every filter you add narrows the result set further. By default the list returns every OAuth app ever observed, including apps whose grants have all been revoked; pass `hasActiveAccounts=true` to narrow to apps still in use.

The console labels an app with no classification "Unknown", which is `classification: null` here and the `none` filter value. For what each [classification](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-detections-and-classification) means, see OAuth Detections and Classification.

The `classification` filter matches the **effective** classification, the same value **Explorer** > **Apps** > **OAuth** shows: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`. There is no committed-only filter mode: an app whose committed value differs from a staged recommendation will not match a `classification` query for that committed value at all. To work with committed values, list without the `classification` filter and compare the `classification` field yourself.

**Sorting**

Sort with `?sort=<field>:asc|desc`. Supported fields: `name`, `firstObserved`, `lastNewGrant`, and `activeAccounts`. One field at a time; the default is `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh.

**Accounts**

An OAuth app is only as dangerous as the accounts that granted it, so each app has an accounts collection: **List OAuth App Accounts** pages it, and `include=accounts` on **Get OAuth App** inlines its first page. Each item is the account (encoded ID, email, display name) plus an `edge` object describing that one account's grant: whether it is still `active`, when it was first and most recently granted, and the scopes in that grant, which can be narrower than the app-level `scopes` union.

By default every account that has ever granted the app is listed, revoked grants included, ordered by when each account first granted it, newest first. Pass `active=true` for the accounts that currently grant it, or `active=false` for the ones that no longer do; `providerStatus` narrows by the account's own state instead, and `providerStatus=active` is the population the console's Accounts tab shows for the app. `meta.numActive` counts the currently granting accounts across the whole app and does not move when you filter, so it stays comparable against `meta.totalCount`. An `edge.active` of false covers every way a grant ends: the user revoked it, Material revoked it, or the account was suspended or deleted.

**The `include` Parameter**

Pass **`include=accounts`** on **Get OAuth App** to inline the first page (up to 100) of the accounts collection above. A `meta.hasMore` of true alongside a null `meta.nextCursor` means the app has more accounts than fit inline; page them at **List OAuth App Accounts**.

Pass **`include=investigation`** on **Get OAuth App** to also return the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps): its status, timestamps, overall risk narrative, and per-dimension assessments (vendor trust, scope risk, blast radius, and app behavior). Reading it never starts a new run, and it is null whenever there is no investigation to return. This include requires permission to read investigation data in addition to app access; a token without it gets a 403 rather than a response with the field quietly missing, so a null always means "there is nothing to show" and never "not allowed to see it". The narrative fields are written by the OAuth Remediation Agent and quote app metadata the app publisher controls, so treat them as untrusted text.

Pass **`include=*`** to return both.

**Revoking One Account's Grant**

[**Revoke OAuth App Account Grant**](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke) revokes a single account's grant of an app at the provider and returns that account's updated entry from the accounts collection.

This is a one-off action, not a policy. It leaves the app's `remediation` untouched, so if the account grants the app again, Material won't revoke it automatically. Use `remediation` when you want that. The whole grant goes rather than selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing.

A 403 here means [OAuth app remediation](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-remediation-responses) is turned off for that account, which is a per-account setting rather than a property of your token.

**Updating an App**

An app's identity, scopes, brand, and grant history are observed from Google and read-only. The two fields you can set are `classification` and `remediation`; only fields present in the body change.

`remediation` is a **standing policy, not a one-shot action**. `["revoke"]` or `["revoke", "notify"]` tells Material to keep revoking this app's grants, including for accounts that grant it later. The work is asynchronous, so a 200 records the policy rather than confirming a revocation. `[]` removes the policy, which stops future enforcement but does not restore revoked grants. `notify` cannot be sent alone, since it means notifying the accounts whose grants were revoked.

Setting `classification` resolves a staged agent recommendation that carries an `issueId` to `accepted` or `rejected` and mirrors onto the app's open investigation issues. Classification never implies a remediation: set both fields explicitly when you want both. Because remediation is a standing policy, any update to an app that already carries one, including a classification-only update, can enforce it against accounts that have granted the app since the last enforcement pass. There is no way to return an app to unclassified.

**Response Structure**

List responses follow the standard two-part structure: a `meta` object (with `totalCount`, `limit`, `hasMore`, and `nextCursor`) and an `items` array. **List OAuth Apps** items are OAuth app objects; **List OAuth App Accounts** items are accounts carrying the grant under `edge`, and its `meta` adds `numActive`.

**Want a walkthrough?**

Review [Hello World](https://docs.material.security/reference/api-v1/hello-world).

**Pagination and Rate Limiting**

* Material APIs use **cursor-based pagination**: pass the `nextCursor` value from one response as the `cursor` parameter in the next request.
* The `limit` parameter controls page size and is locked into the cursor, so later pages keep the page size of the first request.
* When `hasMore` is false and `nextCursor` is null, you've reached the last page.
* Each page is a point-in-time window over a live inventory. Apps sort in a stable total order, but an app observed while you are paging shifts later rows, so consecutive pages can still repeat or skip one. Reconcile paged results by `clientId` when you need exactness, and re-fetch specific apps with the `clientId` filter.

## List OAuth Apps

> Retrieves a paginated list of the third-party OAuth applications observed holding token grants in your Google Workspace tenant (the only provider covered today). By default every app ever observed is returned, including ones whose grants have all been revoked; use \`hasActiveAccounts\` to narrow to apps in use. Filter by classification, remediation policy, brand verification, restricted scopes, and more, and sort by name, grant times, or account count. The \`classification\` filter matches the effective classification \*\*Explorer\*\* > \*\*Apps\*\* > \*\*OAuth\*\* shows, so an app with a staged agent recommendation matches that recommendation rather than its committed value.

```json
{"openapi":"3.1.0","info":{"title":"Material Security API","version":"v1"},"tags":[{"name":"OAuth Apps","description":"**Overview**\n\nThe OAuth Apps API provides programmatic access to the third-party applications that hold [OAuth token grants](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) in your tenant. Google Workspace is the only provider covered today, so every app returned is a Google OAuth client. Each app exposes its Google client ID, the scopes currently granted across your accounts, the restricted-scope families those scopes fall into, brand metadata and verification status, its committed classification, any classification recommended by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps), and the standing remediation policy assigned to it.\n\nFive endpoints make up the OAuth Apps API:\n\n* **[List OAuth Apps](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps)** (`GET /api/v1/oauth-apps`): filter, sort, and page through OAuth apps\n* **[Get OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id)** (`GET /api/v1/oauth-apps/{id}`): retrieve a single OAuth app by its encoded ID\n* **[List OAuth App Accounts](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id-accounts)** (`GET /api/v1/oauth-apps/{id}/accounts`): page the accounts that have granted one app\n* **[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** (`POST /api/v1/oauth-apps/{id}/accounts/{accountId}/revoke`): revoke one account's grant of an app\n* **[Update OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#patch-api-v1-oauth-apps-id)** (`PATCH /api/v1/oauth-apps/{id}`): set the classification and the remediation policy\n\n**Filtering**\n\nList accepts `search` (substring match over app name or client ID), `clientId` (exact match, the way to resolve a client ID from a Google Workspace audit log), `classification`, `remediation`, `hasActiveAccounts`, `verified`, `internal`, `loginOnly`, `restrictedScopeType`, and `tenant` (encoded tenant IDs; always intersected with the tenants your token can reach, so an unreachable ID matches nothing). Comma-separated values match any of the listed values within a single filter, and every filter you add narrows the result set further. By default the list returns every OAuth app ever observed, including apps whose grants have all been revoked; pass `hasActiveAccounts=true` to narrow to apps still in use.\n\nThe console labels an app with no classification \"Unknown\", which is `classification: null` here and the `none` filter value. For what each [classification](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-detections-and-classification) means, see OAuth Detections and Classification.\n\nThe `classification` filter matches the **effective** classification, the same value **Explorer** > **Apps** > **OAuth** shows: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`. There is no committed-only filter mode: an app whose committed value differs from a staged recommendation will not match a `classification` query for that committed value at all. To work with committed values, list without the `classification` filter and compare the `classification` field yourself.\n\n**Sorting**\n\nSort with `?sort=<field>:asc|desc`. Supported fields: `name`, `firstObserved`, `lastNewGrant`, and `activeAccounts`. One field at a time; the default is `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh.\n\n**Accounts**\n\nAn OAuth app is only as dangerous as the accounts that granted it, so each app has an accounts collection: **List OAuth App Accounts** pages it, and `include=accounts` on **Get OAuth App** inlines its first page. Each item is the account (encoded ID, email, display name) plus an `edge` object describing that one account's grant: whether it is still `active`, when it was first and most recently granted, and the scopes in that grant, which can be narrower than the app-level `scopes` union.\n\nBy default every account that has ever granted the app is listed, revoked grants included, ordered by when each account first granted it, newest first. Pass `active=true` for the accounts that currently grant it, or `active=false` for the ones that no longer do; `providerStatus` narrows by the account's own state instead, and `providerStatus=active` is the population the console's Accounts tab shows for the app. `meta.numActive` counts the currently granting accounts across the whole app and does not move when you filter, so it stays comparable against `meta.totalCount`. An `edge.active` of false covers every way a grant ends: the user revoked it, Material revoked it, or the account was suspended or deleted.\n\n**The `include` Parameter**\n\nPass **`include=accounts`** on **Get OAuth App** to inline the first page (up to 100) of the accounts collection above. A `meta.hasMore` of true alongside a null `meta.nextCursor` means the app has more accounts than fit inline; page them at **List OAuth App Accounts**.\n\nPass **`include=investigation`** on **Get OAuth App** to also return the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps): its status, timestamps, overall risk narrative, and per-dimension assessments (vendor trust, scope risk, blast radius, and app behavior). Reading it never starts a new run, and it is null whenever there is no investigation to return. This include requires permission to read investigation data in addition to app access; a token without it gets a 403 rather than a response with the field quietly missing, so a null always means \"there is nothing to show\" and never \"not allowed to see it\". The narrative fields are written by the OAuth Remediation Agent and quote app metadata the app publisher controls, so treat them as untrusted text.\n\nPass **`include=*`** to return both.\n\n**Revoking One Account's Grant**\n\n**[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** revokes a single account's grant of an app at the provider and returns that account's updated entry from the accounts collection.\n\nThis is a one-off action, not a policy. It leaves the app's `remediation` untouched, so if the account grants the app again, Material won't revoke it automatically. Use `remediation` when you want that. The whole grant goes rather than selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing.\n\nA 403 here means [OAuth app remediation](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-remediation-responses) is turned off for that account, which is a per-account setting rather than a property of your token.\n\n**Updating an App**\n\nAn app's identity, scopes, brand, and grant history are observed from Google and read-only. The two fields you can set are `classification` and `remediation`; only fields present in the body change.\n\n`remediation` is a **standing policy, not a one-shot action**. `[\"revoke\"]` or `[\"revoke\", \"notify\"]` tells Material to keep revoking this app's grants, including for accounts that grant it later. The work is asynchronous, so a 200 records the policy rather than confirming a revocation. `[]` removes the policy, which stops future enforcement but does not restore revoked grants. `notify` cannot be sent alone, since it means notifying the accounts whose grants were revoked.\n\nSetting `classification` resolves a staged agent recommendation that carries an `issueId` to `accepted` or `rejected` and mirrors onto the app's open investigation issues. Classification never implies a remediation: set both fields explicitly when you want both. Because remediation is a standing policy, any update to an app that already carries one, including a classification-only update, can enforce it against accounts that have granted the app since the last enforcement pass. There is no way to return an app to unclassified.\n\n**Response Structure**\n\nList responses follow the standard two-part structure: a `meta` object (with `totalCount`, `limit`, `hasMore`, and `nextCursor`) and an `items` array. **List OAuth Apps** items are OAuth app objects; **List OAuth App Accounts** items are accounts carrying the grant under `edge`, and its `meta` adds `numActive`.\n\n**Want a walkthrough?**\n\nReview [Hello World](https://docs.material.security/reference/api-v1/hello-world).\n\n**Pagination and Rate Limiting**\n\n* Material APIs use **cursor-based pagination**: pass the `nextCursor` value from one response as the `cursor` parameter in the next request.\n* The `limit` parameter controls page size and is locked into the cursor, so later pages keep the page size of the first request.\n* When `hasMore` is false and `nextCursor` is null, you've reached the last page.\n* Each page is a point-in-time window over a live inventory. Apps sort in a stable total order, but an app observed while you are paging shifts later rows, so consecutive pages can still repeat or skip one. Reconcile paged results by `clientId` when you need exactness, and re-fetch specific apps with the `clientId` filter.\n"}],"servers":[{"url":"https://{domain}","description":"Material Security instance","variables":{"domain":{"default":"your-instance.on.material.security","description":"Your Material Security instance domain"}}}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-material-client-secret","description":"Material Security API key"}},"schemas":{"APIOAuthAppCollection":{"title":"APIOAuthAppCollection","type":"object","properties":{"meta":{"$ref":"#/components/schemas/CollectionMeta"},"items":{"type":"array","items":{"$ref":"#/components/schemas/APIOAuthApp"}}},"required":["meta","items"]},"CollectionMeta":{"title":"CollectionMeta","type":"object","properties":{"totalCount":{"description":"The total number of items matching the query.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"limit":{"description":"The page size used for this response.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"hasMore":{"description":"True if more items exist beyond the current page; false otherwise.","type":"boolean"},"nextCursor":{"description":"The cursor to fetch the next page of results. Pass this value to the `cursor` query parameter. Null if no more pages exist.","anyOf":[{"type":"string"},{"type":"null"}]}}},"APIOAuthApp":{"title":"APIOAuthApp","description":"A third-party OAuth application observed holding token grants in your Google Workspace tenant. Google Workspace is the only provider covered today.","type":"object","properties":{"id":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"tenantId":{"description":"The encoded ID of the tenant the app was observed in. Format: `tnt.1.<base64>`.","type":"string"},"name":{"description":"The name Google shows on the consent screen. The app publisher chooses it, so it can impersonate a well-known vendor; use `clientId` to identify an app.","type":"string"},"clientId":{"description":"The Google OAuth client ID. This is the stable identity of the app and the value that appears in Google Workspace audit logs.","type":"string"},"isInternal":{"description":"True when the app's registered brand belongs to one of your own domains (for example an Apps Script written in-house). False when unknown.","type":"boolean"},"isLoginOnly":{"description":"True when every scope currently granted is a sign-in scope (`email`, `profile`, or `openid`), so the app can identify users but read nothing. Derived from the same observed grants as `scopes`, so it carries the same staleness, and it reads false for an app Material has never computed it for.","type":"boolean"},"scopes":{"description":"The union of scopes across the grants Material last observed as active, sorted. This can lag in both directions: a very recent grant may not appear yet, and after every account is revoked at once it can keep listing scopes no account still holds. Use the accounts collection for live per-account grant state.","type":"array","items":{"type":"string"}},"restrictedScopeTypes":{"description":"Which of Google's restricted-scope families the observed granted scopes fall into, sorted. Derived from `scopes`, so it carries the same staleness in both directions.","type":"array","items":{"type":"string","enum":["gmail","drive","google_fit","google_chat","data_portability","photos_ambient"]}},"brand":{"description":"Brand metadata, or null when Google returned no brand record.","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppBrand"},{"type":"null"}]},"classification":{"description":"The committed classification of the app, set by an admin or committed automatically by a `safe` investigation verdict. Null means unclassified, which **Explorer** > **Apps** > **OAuth** labels Unknown. The list `classification` filter matches the effective classification instead, so a filtered result can carry a value here that differs from the one you filtered on. The effective value is derivable from this payload: it is `recommendedClassification.value` while that object has `status: 'staged'`, and this field otherwise.","anyOf":[{"type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},{"type":"null"}]},"recommendedClassification":{"description":"The OAuth Remediation Agent's suggestion and how it was resolved. Null when there is no suggestion.","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppRecommendedClassification"},{"type":"null"}]},"remediation":{"description":"The standing remediation policy for this app, not a record of past actions. `[]` means none. `notify` always accompanies `revoke`, never on its own.","type":"array","items":{"type":"string","enum":["revoke","notify"]}},"keyEvents":{"$ref":"#/components/schemas/APIOAuthAppKeyEvents"},"investigation":{"description":"Populated only when `include=investigation`. Null when there is no investigation available (never investigated, or a stale pointer whose run has been purged). Requesting the include without permission to read investigation data returns 403, so a null here never means \"not allowed to see it\".","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppInvestigation"},{"type":"null"}]},"accounts":{"description":"Populated only when `include=accounts`, with the first page of the accounts edge. `meta.hasMore: true` with a null `meta.nextCursor` means there are more; page them at `GET /oauth-apps/{id}/accounts`.","$ref":"#/components/schemas/APIOAuthAppAccountCollection"}},"required":["id","tenantId","name","clientId","isInternal","isLoginOnly","scopes","restrictedScopeTypes","brand","classification","recommendedClassification","remediation","keyEvents"]},"APIOAuthAppBrand":{"title":"APIOAuthAppBrand","description":"Brand metadata registered with Google for this OAuth client. Null when Google returned no brand record.","type":"object","properties":{"verified":{"description":"Whether Google's brand verification returned a verified brand record for this app. Unverified apps can still set any of the fields below, so treat them as attacker-controllable input.","type":"boolean"},"displayName":{"description":"Brand display name; the verified value when present. Null if the brand has no name.","anyOf":[{"type":"string"},{"type":"null"}]},"supportEmail":{"description":"Brand support email; the verified value when present. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]},"homePageUrl":{"description":"Brand home page URL; the verified value when present. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"description":"URL of the app's consent-screen icon. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["verified","displayName","supportEmail","homePageUrl","iconUrl"]},"APIOAuthAppRecommendedClassification":{"title":"APIOAuthAppRecommendedClassification","description":"The OAuth Remediation Agent's suggested classification, mirrored from the issue that carries it. Null when no issue currently sources one. The investigation of a newly granted app stages whatever it concludes, so `safe` is reachable here; a later re-investigation that concludes `safe` stages nothing, instead committing `classification` directly when the app is unclassified or was system-classified, and dropping the verdict otherwise.","type":"object","properties":{"value":{"description":"The classification Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) recommends.","type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},"status":{"description":"`staged` means the recommendation is awaiting a human decision and is what **Explorer** > **Apps** > **OAuth** displays in place of `classification`. `accepted` and `rejected` record how an admin resolved it; `rejected` leaves `classification` untouched, so the disagreement stays readable.","type":"string","enum":["staged","accepted","rejected"]},"issueId":{"description":"The audit issue that carries this recommendation. Null when no issue exists. Reading it requires issue permissions.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["value","status","issueId"]},"APIOAuthAppKeyEvents":{"title":"APIOAuthAppKeyEvents","type":"object","properties":{"created":{"description":"When Material created this app record, which is the same sync that first observed a grant, so it normally matches `firstObserved`.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"firstObserved":{"description":"The earliest grant of this app Material has observed. Google does not report when a grant was actually made, so for apps already present when Material first synced the tenant this is the first-sync time rather than the original grant date. Reflects grants that still exist: if the earliest granting account is deleted, this moves forward.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"lastNewGrant":{"description":"When this app was most recently granted by an account that did not already grant it. An existing account re-granting after revoking does not advance this; the per-account `lastGrantedAt` on the accounts collection does.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"classificationChanged":{"description":"When `classification` was last written, including a re-save of the same value. Absent while the app has no classification, which also covers a classification that was later cleared.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"remediationChanged":{"description":"When the remediation policy was last written, including a write that set it to none. Present alongside an empty `remediation` whenever a classification was saved without one.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]}},"required":["created","firstObserved","lastNewGrant"]},"APIOAuthAppInvestigation":{"title":"APIOAuthAppInvestigation","description":"The most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) for this app. Reading this never starts a new run.","type":"object","properties":{"status":{"description":"The state of the run: `running`, `completed`, or `failed`. Check this rather than the text fields to tell whether a run has finished.","type":"string","enum":["running","completed","failed"]},"startedAt":{"description":"When the run started, in ISO 8601 format. For runs reconciled from an existing issue this is taken from the issue rather than the run, so it can fall after `completedAt`; treat the pair as bounds rather than a duration.","type":"string","format":"date-time"},"completedAt":{"description":"When the run finished, in ISO 8601 format. Null while running.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"riskAssessment":{"description":"The overall risk narrative, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). Empty until the run completes. It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"},"assessments":{"$ref":"#/components/schemas/APIOAuthAppInvestigationAssessments"}},"required":["status","startedAt","completedAt","riskAssessment","assessments"]},"APIOAuthAppInvestigationAssessments":{"title":"APIOAuthAppInvestigationAssessments","description":"Per-dimension verdicts. Each is null when the run produced no assessment for that dimension.","type":"object","properties":{"vendorTrust":{"anyOf":[{"$ref":"#/components/schemas/APIVendorTrustAssessment"},{"type":"null"}]},"scopeRisk":{"anyOf":[{"$ref":"#/components/schemas/APIScopeRiskAssessment"},{"type":"null"}]},"blastRadius":{"anyOf":[{"$ref":"#/components/schemas/APIBlastRadiusAssessment"},{"type":"null"}]},"appBehavior":{"anyOf":[{"$ref":"#/components/schemas/APIAppBehaviorAssessment"},{"type":"null"}]}},"required":["vendorTrust","scopeRisk","blastRadius","appBehavior"]},"APIVendorTrustAssessment":{"title":"APIVendorTrustAssessment","description":"How well-known and reputable the app publisher is.","type":"object","properties":{"level":{"description":"How much the app publisher is trusted: `very_high`, `high`, `medium`, `low`, or `distrust`.","type":"string","enum":["very_high","high","medium","low","distrust"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIScopeRiskAssessment":{"title":"APIScopeRiskAssessment","description":"How much damage the scopes this app holds would allow.","type":"object","properties":{"level":{"description":"How dangerous the granted scopes are: `very_low`, `low`, `medium`, `high`, or `critical`.","type":"string","enum":["very_low","low","medium","high","critical"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIBlastRadiusAssessment":{"title":"APIBlastRadiusAssessment","description":"How much of the tenant is exposed through this app.","type":"object","properties":{"level":{"description":"How widely the app is granted across your accounts: `small`, `moderate`, or `large`.","type":"string","enum":["small","moderate","large"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIAppBehaviorAssessment":{"title":"APIAppBehaviorAssessment","description":"How the app's observed API activity reads.","type":"object","properties":{"level":{"description":"What the observed behavior looks like: `benign`, `unclear`, or `suspicious`.","type":"string","enum":["benign","unclear","suspicious"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIOAuthAppAccountCollection":{"title":"APIOAuthAppAccountCollection","type":"object","properties":{"meta":{"type":"object","properties":{"totalCount":{"description":"The total number of items matching the query.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"limit":{"description":"The page size used for this response.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"hasMore":{"description":"True if more items exist beyond the current page; false otherwise.","type":"boolean"},"nextCursor":{"description":"The cursor to fetch the next page of results. Pass this value to the `cursor` query parameter. Null if no more pages exist.","anyOf":[{"type":"string"},{"type":"null"}]},"numActive":{"description":"How many accounts currently grant this app, counted live for this request. Counted across every account, not just this page, and unaffected by the filters, so it stays comparable as you filter. The `hasActiveAccounts` filter and the `activeAccounts` sort on the app list read a cached count instead, so this is the figure to trust when the two disagree.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["numActive"]},"items":{"type":"array","items":{"$ref":"#/components/schemas/APIOAuthAppAccount"}}},"required":["meta","items"]},"APIOAuthAppAccount":{"title":"APIOAuthAppAccount","description":"An account that has granted this OAuth app, with the grant itself under `edge`. An account appears here once it has ever granted the app, whether or not it still does. Membership is decided by the grant, not by the account directory, so this is not a filtered view of `/accounts` and does not apply that resource's rules about which accounts are listed. Every account here is a licensed member of the tenant whose grant Material recorded.","type":"object","properties":{"id":{"description":"The encoded account ID. Format: `acct.1.<base64>`.","type":"string"},"email":{"description":"Null when Material holds no email address for the account, or when your token cannot read the account. The latter means the account has since moved to a domain your token cannot reach; the grant it made while in this one still lists, so the app's reach is not understated. Narrow with `providerStatus` to drop those rows.","anyOf":[{"type":"string"},{"type":"null"}]},"displayName":{"description":"Null when Material holds no display name for the account, or when your token cannot read the account, on the same terms as `email`.","anyOf":[{"type":"string"},{"type":"null"}]},"edge":{"$ref":"#/components/schemas/APIOAuthAppGrant"}},"required":["id","email","displayName","edge"]},"APIOAuthAppGrant":{"title":"APIOAuthAppGrant","description":"This account's grant of this app. Describes the pair, never the account on its own.","type":"object","properties":{"active":{"description":"Whether this account currently grants the app. False covers every way a grant can end: the account revoked it, Material revoked it, or the account was suspended or deleted. Reflects the last completed sync.","type":"boolean"},"firstGrantedAt":{"description":"When Material first observed this account granting the app, in ISO 8601 format. Google does not report when a grant was actually made, so for a grant that already existed when Material first synced the tenant this is the first-sync time rather than the original grant date. The app-level `keyEvents.firstObserved` is the earliest of these across every account.","type":"string","format":"date-time"},"lastGrantedAt":{"description":"When Material most recently observed this account granting the app, in ISO 8601 format. Equal to `firstGrantedAt` unless the account granted the app again after an earlier grant had ended. Not the per-account form of `keyEvents.lastNewGrant`: that one moves only when an account that did not already grant the app grants it, so a re-grant advances this and leaves it alone.","type":"string","format":"date-time"},"scopes":{"description":"The scopes in this account's own grant, which can be narrower than the app-level `scopes` union.","type":"array","items":{"type":"string"}}},"required":["active","firstGrantedAt","lastGrantedAt","scopes"]},"ValidationErrorResponse":{"title":"Validation Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"},"validationFailures":{"description":"List of individual validation failures when request parameters fail schema validation.","type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}},"required":["error"]},"ErrorResponse":{"title":"Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"}},"required":["error"]}}},"paths":{"/api/v1/oauth-apps":{"get":{"operationId":"listOauthApps","summary":"List OAuth Apps","description":"Retrieves a paginated list of the third-party OAuth applications observed holding token grants in your Google Workspace tenant (the only provider covered today). By default every app ever observed is returned, including ones whose grants have all been revoked; use `hasActiveAccounts` to narrow to apps in use. Filter by classification, remediation policy, brand verification, restricted scopes, and more, and sort by name, grant times, or account count. The `classification` filter matches the effective classification **Explorer** > **Apps** > **OAuth** shows, so an app with a staged agent recommendation matches that recommendation rather than its committed value.","tags":["OAuth Apps"],"parameters":[{"in":"query","name":"cursor","schema":{"description":"The pagination cursor returned in a previous response. Pass this value to retrieve the next page of results.","type":"string"},"description":"The pagination cursor returned in a previous response. Pass this value to retrieve the next page of results."},{"in":"query","name":"limit","schema":{"description":"The maximum number of items to return per page.","type":"integer"},"description":"The maximum number of items to return per page."},{"in":"query","name":"tenant","schema":{"description":"Filter to apps observed in any of these tenants, comma-separated encoded tenant IDs (format `tnt.1.<base64>`). Matches the `tenantId` field on each app; the param is named `tenant` to match the same filter on other resources. Results are always intersected with the tenants your API token can reach, so an id you cannot access matches nothing rather than erroring.","type":"string"},"description":"Filter to apps observed in any of these tenants, comma-separated encoded tenant IDs (format `tnt.1.<base64>`). Matches the `tenantId` field on each app; the param is named `tenant` to match the same filter on other resources. Results are always intersected with the tenants your API token can reach, so an id you cannot access matches nothing rather than erroring."},{"in":"query","name":"clientId","schema":{"description":"Filter to these Google OAuth client IDs, comma-separated. Exact, case-sensitive match on the `clientId` field, unlike `search`, which is a substring match over the name as well. A client ID is unique within a tenant, so this is the way to resolve a client ID seen in a Google Workspace audit log to an app, and the way to reconcile paged results exactly.","type":"string"},"description":"Filter to these Google OAuth client IDs, comma-separated. Exact, case-sensitive match on the `clientId` field, unlike `search`, which is a substring match over the name as well. A client ID is unique within a tenant, so this is the way to resolve a client ID seen in a Google Workspace audit log to an app, and the way to reconcile paged results exactly."},{"in":"query","name":"classification","schema":{"description":"Filter by classification, comma-separated. Values: `safe` (Safe), `unnecessary` (Unnecessary), `overprivileged` (Overprivileged), `suspicious` (Suspicious), `malicious` (Malicious), plus `none` for apps with no classification, which **Explorer** > **Apps** > **OAuth** labels Unknown. Matching uses the effective classification: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`.","type":"string"},"description":"Filter by classification, comma-separated. Values: `safe` (Safe), `unnecessary` (Unnecessary), `overprivileged` (Overprivileged), `suspicious` (Suspicious), `malicious` (Malicious), plus `none` for apps with no classification, which **Explorer** > **Apps** > **OAuth** labels Unknown. Matching uses the effective classification: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`."},{"in":"query","name":"remediation","schema":{"description":"Filter to apps with a standing remediation policy. `revoke` matches any policy; `notify` matches the subset that also notifies, so `notify` results are always a subset of `revoke` results.","type":"string","enum":["revoke","notify"]},"description":"Filter to apps with a standing remediation policy. `revoke` matches any policy; `notify` matches the subset that also notifies, so `notify` results are always a subset of `revoke` results."},{"in":"query","name":"hasActiveAccounts","schema":{"description":"Filter to apps that currently have at least one account granting them (`true`) or none (`false`). Omit to list every OAuth app ever observed, including fully revoked ones. Evaluated against a cached count that a tenant-wide revoke does not immediately refresh, so an app can still match `true` for a while after its last grant ends; `meta.numActive` on the accounts collection is the live figure.","type":"boolean"},"description":"Filter to apps that currently have at least one account granting them (`true`) or none (`false`). Omit to list every OAuth app ever observed, including fully revoked ones. Evaluated against a cached count that a tenant-wide revoke does not immediately refresh, so an app can still match `true` for a while after its last grant ends; `meta.numActive` on the accounts collection is the live figure."},{"in":"query","name":"verified","schema":{"description":"Filter by whether Google's brand verification returned a verified brand record.","type":"boolean"},"description":"Filter by whether Google's brand verification returned a verified brand record."},{"in":"query","name":"internal","schema":{"description":"Filter to apps whose brand belongs to one of your own domains. `false` also matches apps where this has not been determined.","type":"boolean"},"description":"Filter to apps whose brand belongs to one of your own domains. `false` also matches apps where this has not been determined."},{"in":"query","name":"loginOnly","schema":{"description":"Filter to apps whose currently granted scopes are sign-in scopes only. `false` matches only apps Material has computed this for, so an app it never computed reads `isLoginOnly: false` yet matches neither filter value. That population is apps with no grant records left, which are also the apps `hasActiveAccounts=false` returns.","type":"boolean"},"description":"Filter to apps whose currently granted scopes are sign-in scopes only. `false` matches only apps Material has computed this for, so an app it never computed reads `isLoginOnly: false` yet matches neither filter value. That population is apps with no grant records left, which are also the apps `hasActiveAccounts=false` returns."},{"in":"query","name":"restrictedScopeType","schema":{"description":"Filter to apps granted any of these restricted-scope families, comma-separated. Values: `gmail`, `drive`, `google_fit`, `google_chat`, `data_portability`, `photos_ambient`. Matches on `restrictedScopeTypes`, so it carries that field's staleness.","type":"string"},"description":"Filter to apps granted any of these restricted-scope families, comma-separated. Values: `gmail`, `drive`, `google_fit`, `google_chat`, `data_portability`, `photos_ambient`. Matches on `restrictedScopeTypes`, so it carries that field's staleness."},{"in":"query","name":"search","schema":{"description":"Case-insensitive substring match on the app name or the Google OAuth client ID.","type":"string"},"description":"Case-insensitive substring match on the app name or the Google OAuth client ID."},{"in":"query","name":"sort","schema":{"description":"Sort field, e.g. `name:asc` or `lastNewGrant:desc`. One field only. Supported: `name`, `firstObserved`, `lastNewGrant`, `activeAccounts`. Defaults to `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh. Apps with equal sort values fall back to a stable internal order, so the sort is deterministic; an app observed while you are paging can still shift later rows, so reconcile by `clientId` when exactness matters.","type":"string"},"description":"Sort field, e.g. `name:asc` or `lastNewGrant:desc`. One field only. Supported: `name`, `firstObserved`, `lastNewGrant`, `activeAccounts`. Defaults to `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh. Apps with equal sort values fall back to a stable internal order, so the sort is deterministic; an app observed while you are paging can still shift later rows, so reconcile by `clientId` when exactness matters."}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIOAuthAppCollection"}}}},"400":{"description":"Bad request: invalid parameters or request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"401":{"description":"Unauthorized: missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden: the API key doesn't have permission to perform this action","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not found: the requested resource doesn't exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests: the client has exceeded a rate limit and should retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## Get OAuth App

> Retrieves a single OAuth application by its encoded ID, including its Google client ID, currently granted scopes, brand metadata, classification, and standing remediation policy. Use \`include=accounts\` to also return the first page of the accounts that have granted it, and \`include=investigation\` for the most recent run of Material's \[OAuth Remediation Agent]\(<https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps>).

```json
{"openapi":"3.1.0","info":{"title":"Material Security API","version":"v1"},"tags":[{"name":"OAuth Apps","description":"**Overview**\n\nThe OAuth Apps API provides programmatic access to the third-party applications that hold [OAuth token grants](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) in your tenant. Google Workspace is the only provider covered today, so every app returned is a Google OAuth client. Each app exposes its Google client ID, the scopes currently granted across your accounts, the restricted-scope families those scopes fall into, brand metadata and verification status, its committed classification, any classification recommended by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps), and the standing remediation policy assigned to it.\n\nFive endpoints make up the OAuth Apps API:\n\n* **[List OAuth Apps](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps)** (`GET /api/v1/oauth-apps`): filter, sort, and page through OAuth apps\n* **[Get OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id)** (`GET /api/v1/oauth-apps/{id}`): retrieve a single OAuth app by its encoded ID\n* **[List OAuth App Accounts](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id-accounts)** (`GET /api/v1/oauth-apps/{id}/accounts`): page the accounts that have granted one app\n* **[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** (`POST /api/v1/oauth-apps/{id}/accounts/{accountId}/revoke`): revoke one account's grant of an app\n* **[Update OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#patch-api-v1-oauth-apps-id)** (`PATCH /api/v1/oauth-apps/{id}`): set the classification and the remediation policy\n\n**Filtering**\n\nList accepts `search` (substring match over app name or client ID), `clientId` (exact match, the way to resolve a client ID from a Google Workspace audit log), `classification`, `remediation`, `hasActiveAccounts`, `verified`, `internal`, `loginOnly`, `restrictedScopeType`, and `tenant` (encoded tenant IDs; always intersected with the tenants your token can reach, so an unreachable ID matches nothing). Comma-separated values match any of the listed values within a single filter, and every filter you add narrows the result set further. By default the list returns every OAuth app ever observed, including apps whose grants have all been revoked; pass `hasActiveAccounts=true` to narrow to apps still in use.\n\nThe console labels an app with no classification \"Unknown\", which is `classification: null` here and the `none` filter value. For what each [classification](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-detections-and-classification) means, see OAuth Detections and Classification.\n\nThe `classification` filter matches the **effective** classification, the same value **Explorer** > **Apps** > **OAuth** shows: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`. There is no committed-only filter mode: an app whose committed value differs from a staged recommendation will not match a `classification` query for that committed value at all. To work with committed values, list without the `classification` filter and compare the `classification` field yourself.\n\n**Sorting**\n\nSort with `?sort=<field>:asc|desc`. Supported fields: `name`, `firstObserved`, `lastNewGrant`, and `activeAccounts`. One field at a time; the default is `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh.\n\n**Accounts**\n\nAn OAuth app is only as dangerous as the accounts that granted it, so each app has an accounts collection: **List OAuth App Accounts** pages it, and `include=accounts` on **Get OAuth App** inlines its first page. Each item is the account (encoded ID, email, display name) plus an `edge` object describing that one account's grant: whether it is still `active`, when it was first and most recently granted, and the scopes in that grant, which can be narrower than the app-level `scopes` union.\n\nBy default every account that has ever granted the app is listed, revoked grants included, ordered by when each account first granted it, newest first. Pass `active=true` for the accounts that currently grant it, or `active=false` for the ones that no longer do; `providerStatus` narrows by the account's own state instead, and `providerStatus=active` is the population the console's Accounts tab shows for the app. `meta.numActive` counts the currently granting accounts across the whole app and does not move when you filter, so it stays comparable against `meta.totalCount`. An `edge.active` of false covers every way a grant ends: the user revoked it, Material revoked it, or the account was suspended or deleted.\n\n**The `include` Parameter**\n\nPass **`include=accounts`** on **Get OAuth App** to inline the first page (up to 100) of the accounts collection above. A `meta.hasMore` of true alongside a null `meta.nextCursor` means the app has more accounts than fit inline; page them at **List OAuth App Accounts**.\n\nPass **`include=investigation`** on **Get OAuth App** to also return the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps): its status, timestamps, overall risk narrative, and per-dimension assessments (vendor trust, scope risk, blast radius, and app behavior). Reading it never starts a new run, and it is null whenever there is no investigation to return. This include requires permission to read investigation data in addition to app access; a token without it gets a 403 rather than a response with the field quietly missing, so a null always means \"there is nothing to show\" and never \"not allowed to see it\". The narrative fields are written by the OAuth Remediation Agent and quote app metadata the app publisher controls, so treat them as untrusted text.\n\nPass **`include=*`** to return both.\n\n**Revoking One Account's Grant**\n\n**[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** revokes a single account's grant of an app at the provider and returns that account's updated entry from the accounts collection.\n\nThis is a one-off action, not a policy. It leaves the app's `remediation` untouched, so if the account grants the app again, Material won't revoke it automatically. Use `remediation` when you want that. The whole grant goes rather than selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing.\n\nA 403 here means [OAuth app remediation](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-remediation-responses) is turned off for that account, which is a per-account setting rather than a property of your token.\n\n**Updating an App**\n\nAn app's identity, scopes, brand, and grant history are observed from Google and read-only. The two fields you can set are `classification` and `remediation`; only fields present in the body change.\n\n`remediation` is a **standing policy, not a one-shot action**. `[\"revoke\"]` or `[\"revoke\", \"notify\"]` tells Material to keep revoking this app's grants, including for accounts that grant it later. The work is asynchronous, so a 200 records the policy rather than confirming a revocation. `[]` removes the policy, which stops future enforcement but does not restore revoked grants. `notify` cannot be sent alone, since it means notifying the accounts whose grants were revoked.\n\nSetting `classification` resolves a staged agent recommendation that carries an `issueId` to `accepted` or `rejected` and mirrors onto the app's open investigation issues. Classification never implies a remediation: set both fields explicitly when you want both. Because remediation is a standing policy, any update to an app that already carries one, including a classification-only update, can enforce it against accounts that have granted the app since the last enforcement pass. There is no way to return an app to unclassified.\n\n**Response Structure**\n\nList responses follow the standard two-part structure: a `meta` object (with `totalCount`, `limit`, `hasMore`, and `nextCursor`) and an `items` array. **List OAuth Apps** items are OAuth app objects; **List OAuth App Accounts** items are accounts carrying the grant under `edge`, and its `meta` adds `numActive`.\n\n**Want a walkthrough?**\n\nReview [Hello World](https://docs.material.security/reference/api-v1/hello-world).\n\n**Pagination and Rate Limiting**\n\n* Material APIs use **cursor-based pagination**: pass the `nextCursor` value from one response as the `cursor` parameter in the next request.\n* The `limit` parameter controls page size and is locked into the cursor, so later pages keep the page size of the first request.\n* When `hasMore` is false and `nextCursor` is null, you've reached the last page.\n* Each page is a point-in-time window over a live inventory. Apps sort in a stable total order, but an app observed while you are paging shifts later rows, so consecutive pages can still repeat or skip one. Reconcile paged results by `clientId` when you need exactness, and re-fetch specific apps with the `clientId` filter.\n"}],"servers":[{"url":"https://{domain}","description":"Material Security instance","variables":{"domain":{"default":"your-instance.on.material.security","description":"Your Material Security instance domain"}}}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-material-client-secret","description":"Material Security API key"}},"schemas":{"APIOAuthApp":{"title":"APIOAuthApp","description":"A third-party OAuth application observed holding token grants in your Google Workspace tenant. Google Workspace is the only provider covered today.","type":"object","properties":{"id":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"tenantId":{"description":"The encoded ID of the tenant the app was observed in. Format: `tnt.1.<base64>`.","type":"string"},"name":{"description":"The name Google shows on the consent screen. The app publisher chooses it, so it can impersonate a well-known vendor; use `clientId` to identify an app.","type":"string"},"clientId":{"description":"The Google OAuth client ID. This is the stable identity of the app and the value that appears in Google Workspace audit logs.","type":"string"},"isInternal":{"description":"True when the app's registered brand belongs to one of your own domains (for example an Apps Script written in-house). False when unknown.","type":"boolean"},"isLoginOnly":{"description":"True when every scope currently granted is a sign-in scope (`email`, `profile`, or `openid`), so the app can identify users but read nothing. Derived from the same observed grants as `scopes`, so it carries the same staleness, and it reads false for an app Material has never computed it for.","type":"boolean"},"scopes":{"description":"The union of scopes across the grants Material last observed as active, sorted. This can lag in both directions: a very recent grant may not appear yet, and after every account is revoked at once it can keep listing scopes no account still holds. Use the accounts collection for live per-account grant state.","type":"array","items":{"type":"string"}},"restrictedScopeTypes":{"description":"Which of Google's restricted-scope families the observed granted scopes fall into, sorted. Derived from `scopes`, so it carries the same staleness in both directions.","type":"array","items":{"type":"string","enum":["gmail","drive","google_fit","google_chat","data_portability","photos_ambient"]}},"brand":{"description":"Brand metadata, or null when Google returned no brand record.","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppBrand"},{"type":"null"}]},"classification":{"description":"The committed classification of the app, set by an admin or committed automatically by a `safe` investigation verdict. Null means unclassified, which **Explorer** > **Apps** > **OAuth** labels Unknown. The list `classification` filter matches the effective classification instead, so a filtered result can carry a value here that differs from the one you filtered on. The effective value is derivable from this payload: it is `recommendedClassification.value` while that object has `status: 'staged'`, and this field otherwise.","anyOf":[{"type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},{"type":"null"}]},"recommendedClassification":{"description":"The OAuth Remediation Agent's suggestion and how it was resolved. Null when there is no suggestion.","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppRecommendedClassification"},{"type":"null"}]},"remediation":{"description":"The standing remediation policy for this app, not a record of past actions. `[]` means none. `notify` always accompanies `revoke`, never on its own.","type":"array","items":{"type":"string","enum":["revoke","notify"]}},"keyEvents":{"$ref":"#/components/schemas/APIOAuthAppKeyEvents"},"investigation":{"description":"Populated only when `include=investigation`. Null when there is no investigation available (never investigated, or a stale pointer whose run has been purged). Requesting the include without permission to read investigation data returns 403, so a null here never means \"not allowed to see it\".","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppInvestigation"},{"type":"null"}]},"accounts":{"description":"Populated only when `include=accounts`, with the first page of the accounts edge. `meta.hasMore: true` with a null `meta.nextCursor` means there are more; page them at `GET /oauth-apps/{id}/accounts`.","$ref":"#/components/schemas/APIOAuthAppAccountCollection"}},"required":["id","tenantId","name","clientId","isInternal","isLoginOnly","scopes","restrictedScopeTypes","brand","classification","recommendedClassification","remediation","keyEvents"]},"APIOAuthAppBrand":{"title":"APIOAuthAppBrand","description":"Brand metadata registered with Google for this OAuth client. Null when Google returned no brand record.","type":"object","properties":{"verified":{"description":"Whether Google's brand verification returned a verified brand record for this app. Unverified apps can still set any of the fields below, so treat them as attacker-controllable input.","type":"boolean"},"displayName":{"description":"Brand display name; the verified value when present. Null if the brand has no name.","anyOf":[{"type":"string"},{"type":"null"}]},"supportEmail":{"description":"Brand support email; the verified value when present. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]},"homePageUrl":{"description":"Brand home page URL; the verified value when present. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"description":"URL of the app's consent-screen icon. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["verified","displayName","supportEmail","homePageUrl","iconUrl"]},"APIOAuthAppRecommendedClassification":{"title":"APIOAuthAppRecommendedClassification","description":"The OAuth Remediation Agent's suggested classification, mirrored from the issue that carries it. Null when no issue currently sources one. The investigation of a newly granted app stages whatever it concludes, so `safe` is reachable here; a later re-investigation that concludes `safe` stages nothing, instead committing `classification` directly when the app is unclassified or was system-classified, and dropping the verdict otherwise.","type":"object","properties":{"value":{"description":"The classification Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) recommends.","type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},"status":{"description":"`staged` means the recommendation is awaiting a human decision and is what **Explorer** > **Apps** > **OAuth** displays in place of `classification`. `accepted` and `rejected` record how an admin resolved it; `rejected` leaves `classification` untouched, so the disagreement stays readable.","type":"string","enum":["staged","accepted","rejected"]},"issueId":{"description":"The audit issue that carries this recommendation. Null when no issue exists. Reading it requires issue permissions.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["value","status","issueId"]},"APIOAuthAppKeyEvents":{"title":"APIOAuthAppKeyEvents","type":"object","properties":{"created":{"description":"When Material created this app record, which is the same sync that first observed a grant, so it normally matches `firstObserved`.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"firstObserved":{"description":"The earliest grant of this app Material has observed. Google does not report when a grant was actually made, so for apps already present when Material first synced the tenant this is the first-sync time rather than the original grant date. Reflects grants that still exist: if the earliest granting account is deleted, this moves forward.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"lastNewGrant":{"description":"When this app was most recently granted by an account that did not already grant it. An existing account re-granting after revoking does not advance this; the per-account `lastGrantedAt` on the accounts collection does.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"classificationChanged":{"description":"When `classification` was last written, including a re-save of the same value. Absent while the app has no classification, which also covers a classification that was later cleared.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"remediationChanged":{"description":"When the remediation policy was last written, including a write that set it to none. Present alongside an empty `remediation` whenever a classification was saved without one.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]}},"required":["created","firstObserved","lastNewGrant"]},"APIOAuthAppInvestigation":{"title":"APIOAuthAppInvestigation","description":"The most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) for this app. Reading this never starts a new run.","type":"object","properties":{"status":{"description":"The state of the run: `running`, `completed`, or `failed`. Check this rather than the text fields to tell whether a run has finished.","type":"string","enum":["running","completed","failed"]},"startedAt":{"description":"When the run started, in ISO 8601 format. For runs reconciled from an existing issue this is taken from the issue rather than the run, so it can fall after `completedAt`; treat the pair as bounds rather than a duration.","type":"string","format":"date-time"},"completedAt":{"description":"When the run finished, in ISO 8601 format. Null while running.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"riskAssessment":{"description":"The overall risk narrative, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). Empty until the run completes. It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"},"assessments":{"$ref":"#/components/schemas/APIOAuthAppInvestigationAssessments"}},"required":["status","startedAt","completedAt","riskAssessment","assessments"]},"APIOAuthAppInvestigationAssessments":{"title":"APIOAuthAppInvestigationAssessments","description":"Per-dimension verdicts. Each is null when the run produced no assessment for that dimension.","type":"object","properties":{"vendorTrust":{"anyOf":[{"$ref":"#/components/schemas/APIVendorTrustAssessment"},{"type":"null"}]},"scopeRisk":{"anyOf":[{"$ref":"#/components/schemas/APIScopeRiskAssessment"},{"type":"null"}]},"blastRadius":{"anyOf":[{"$ref":"#/components/schemas/APIBlastRadiusAssessment"},{"type":"null"}]},"appBehavior":{"anyOf":[{"$ref":"#/components/schemas/APIAppBehaviorAssessment"},{"type":"null"}]}},"required":["vendorTrust","scopeRisk","blastRadius","appBehavior"]},"APIVendorTrustAssessment":{"title":"APIVendorTrustAssessment","description":"How well-known and reputable the app publisher is.","type":"object","properties":{"level":{"description":"How much the app publisher is trusted: `very_high`, `high`, `medium`, `low`, or `distrust`.","type":"string","enum":["very_high","high","medium","low","distrust"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIScopeRiskAssessment":{"title":"APIScopeRiskAssessment","description":"How much damage the scopes this app holds would allow.","type":"object","properties":{"level":{"description":"How dangerous the granted scopes are: `very_low`, `low`, `medium`, `high`, or `critical`.","type":"string","enum":["very_low","low","medium","high","critical"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIBlastRadiusAssessment":{"title":"APIBlastRadiusAssessment","description":"How much of the tenant is exposed through this app.","type":"object","properties":{"level":{"description":"How widely the app is granted across your accounts: `small`, `moderate`, or `large`.","type":"string","enum":["small","moderate","large"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIAppBehaviorAssessment":{"title":"APIAppBehaviorAssessment","description":"How the app's observed API activity reads.","type":"object","properties":{"level":{"description":"What the observed behavior looks like: `benign`, `unclear`, or `suspicious`.","type":"string","enum":["benign","unclear","suspicious"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIOAuthAppAccountCollection":{"title":"APIOAuthAppAccountCollection","type":"object","properties":{"meta":{"type":"object","properties":{"totalCount":{"description":"The total number of items matching the query.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"limit":{"description":"The page size used for this response.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"hasMore":{"description":"True if more items exist beyond the current page; false otherwise.","type":"boolean"},"nextCursor":{"description":"The cursor to fetch the next page of results. Pass this value to the `cursor` query parameter. Null if no more pages exist.","anyOf":[{"type":"string"},{"type":"null"}]},"numActive":{"description":"How many accounts currently grant this app, counted live for this request. Counted across every account, not just this page, and unaffected by the filters, so it stays comparable as you filter. The `hasActiveAccounts` filter and the `activeAccounts` sort on the app list read a cached count instead, so this is the figure to trust when the two disagree.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["numActive"]},"items":{"type":"array","items":{"$ref":"#/components/schemas/APIOAuthAppAccount"}}},"required":["meta","items"]},"APIOAuthAppAccount":{"title":"APIOAuthAppAccount","description":"An account that has granted this OAuth app, with the grant itself under `edge`. An account appears here once it has ever granted the app, whether or not it still does. Membership is decided by the grant, not by the account directory, so this is not a filtered view of `/accounts` and does not apply that resource's rules about which accounts are listed. Every account here is a licensed member of the tenant whose grant Material recorded.","type":"object","properties":{"id":{"description":"The encoded account ID. Format: `acct.1.<base64>`.","type":"string"},"email":{"description":"Null when Material holds no email address for the account, or when your token cannot read the account. The latter means the account has since moved to a domain your token cannot reach; the grant it made while in this one still lists, so the app's reach is not understated. Narrow with `providerStatus` to drop those rows.","anyOf":[{"type":"string"},{"type":"null"}]},"displayName":{"description":"Null when Material holds no display name for the account, or when your token cannot read the account, on the same terms as `email`.","anyOf":[{"type":"string"},{"type":"null"}]},"edge":{"$ref":"#/components/schemas/APIOAuthAppGrant"}},"required":["id","email","displayName","edge"]},"APIOAuthAppGrant":{"title":"APIOAuthAppGrant","description":"This account's grant of this app. Describes the pair, never the account on its own.","type":"object","properties":{"active":{"description":"Whether this account currently grants the app. False covers every way a grant can end: the account revoked it, Material revoked it, or the account was suspended or deleted. Reflects the last completed sync.","type":"boolean"},"firstGrantedAt":{"description":"When Material first observed this account granting the app, in ISO 8601 format. Google does not report when a grant was actually made, so for a grant that already existed when Material first synced the tenant this is the first-sync time rather than the original grant date. The app-level `keyEvents.firstObserved` is the earliest of these across every account.","type":"string","format":"date-time"},"lastGrantedAt":{"description":"When Material most recently observed this account granting the app, in ISO 8601 format. Equal to `firstGrantedAt` unless the account granted the app again after an earlier grant had ended. Not the per-account form of `keyEvents.lastNewGrant`: that one moves only when an account that did not already grant the app grants it, so a re-grant advances this and leaves it alone.","type":"string","format":"date-time"},"scopes":{"description":"The scopes in this account's own grant, which can be narrower than the app-level `scopes` union.","type":"array","items":{"type":"string"}}},"required":["active","firstGrantedAt","lastGrantedAt","scopes"]},"ValidationErrorResponse":{"title":"Validation Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"},"validationFailures":{"description":"List of individual validation failures when request parameters fail schema validation.","type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}},"required":["error"]},"ErrorResponse":{"title":"Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"}},"required":["error"]}}},"paths":{"/api/v1/oauth-apps/{id}":{"get":{"operationId":"getOauthApp","summary":"Get OAuth App","description":"Retrieves a single OAuth application by its encoded ID, including its Google client ID, currently granted scopes, brand metadata, classification, and standing remediation policy. Use `include=accounts` to also return the first page of the accounts that have granted it, and `include=investigation` for the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps).","tags":["OAuth Apps"],"parameters":[{"in":"path","name":"id","schema":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"required":true,"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`."},{"in":"query","name":"include","schema":{"description":"Comma-separated list of extra fields to return. Supported: `accounts`, the first page of the accounts edge; and `investigation`, which additionally requires permission to read investigation data and returns 403 without it. Use `*` for all of them.","type":"string"},"description":"Comma-separated list of extra fields to return. Supported: `accounts`, the first page of the accounts edge; and `investigation`, which additionally requires permission to read investigation data and returns 403 without it. Use `*` for all of them."}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIOAuthApp"}}}},"400":{"description":"Bad request: invalid parameters or request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"401":{"description":"Unauthorized: missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden: the API key doesn't have permission to perform this action","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not found: the requested resource doesn't exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests: the client has exceeded a rate limit and should retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## Update OAuth App

> Updates an OAuth app's classification and standing remediation policy. All other fields are read-only. Only provided fields change. A remediation containing \`revoke\` queues revocation of the app's token grants asynchronously, so a 200 records the policy rather than confirming any token is revoked; \`\[]\` removes the policy. Setting a classification also resolves a staged agent recommendation that carries an \`issueId\`, and mirrors onto the app's open investigation issues.

```json
{"openapi":"3.1.0","info":{"title":"Material Security API","version":"v1"},"tags":[{"name":"OAuth Apps","description":"**Overview**\n\nThe OAuth Apps API provides programmatic access to the third-party applications that hold [OAuth token grants](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) in your tenant. Google Workspace is the only provider covered today, so every app returned is a Google OAuth client. Each app exposes its Google client ID, the scopes currently granted across your accounts, the restricted-scope families those scopes fall into, brand metadata and verification status, its committed classification, any classification recommended by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps), and the standing remediation policy assigned to it.\n\nFive endpoints make up the OAuth Apps API:\n\n* **[List OAuth Apps](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps)** (`GET /api/v1/oauth-apps`): filter, sort, and page through OAuth apps\n* **[Get OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id)** (`GET /api/v1/oauth-apps/{id}`): retrieve a single OAuth app by its encoded ID\n* **[List OAuth App Accounts](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id-accounts)** (`GET /api/v1/oauth-apps/{id}/accounts`): page the accounts that have granted one app\n* **[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** (`POST /api/v1/oauth-apps/{id}/accounts/{accountId}/revoke`): revoke one account's grant of an app\n* **[Update OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#patch-api-v1-oauth-apps-id)** (`PATCH /api/v1/oauth-apps/{id}`): set the classification and the remediation policy\n\n**Filtering**\n\nList accepts `search` (substring match over app name or client ID), `clientId` (exact match, the way to resolve a client ID from a Google Workspace audit log), `classification`, `remediation`, `hasActiveAccounts`, `verified`, `internal`, `loginOnly`, `restrictedScopeType`, and `tenant` (encoded tenant IDs; always intersected with the tenants your token can reach, so an unreachable ID matches nothing). Comma-separated values match any of the listed values within a single filter, and every filter you add narrows the result set further. By default the list returns every OAuth app ever observed, including apps whose grants have all been revoked; pass `hasActiveAccounts=true` to narrow to apps still in use.\n\nThe console labels an app with no classification \"Unknown\", which is `classification: null` here and the `none` filter value. For what each [classification](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-detections-and-classification) means, see OAuth Detections and Classification.\n\nThe `classification` filter matches the **effective** classification, the same value **Explorer** > **Apps** > **OAuth** shows: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`. There is no committed-only filter mode: an app whose committed value differs from a staged recommendation will not match a `classification` query for that committed value at all. To work with committed values, list without the `classification` filter and compare the `classification` field yourself.\n\n**Sorting**\n\nSort with `?sort=<field>:asc|desc`. Supported fields: `name`, `firstObserved`, `lastNewGrant`, and `activeAccounts`. One field at a time; the default is `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh.\n\n**Accounts**\n\nAn OAuth app is only as dangerous as the accounts that granted it, so each app has an accounts collection: **List OAuth App Accounts** pages it, and `include=accounts` on **Get OAuth App** inlines its first page. Each item is the account (encoded ID, email, display name) plus an `edge` object describing that one account's grant: whether it is still `active`, when it was first and most recently granted, and the scopes in that grant, which can be narrower than the app-level `scopes` union.\n\nBy default every account that has ever granted the app is listed, revoked grants included, ordered by when each account first granted it, newest first. Pass `active=true` for the accounts that currently grant it, or `active=false` for the ones that no longer do; `providerStatus` narrows by the account's own state instead, and `providerStatus=active` is the population the console's Accounts tab shows for the app. `meta.numActive` counts the currently granting accounts across the whole app and does not move when you filter, so it stays comparable against `meta.totalCount`. An `edge.active` of false covers every way a grant ends: the user revoked it, Material revoked it, or the account was suspended or deleted.\n\n**The `include` Parameter**\n\nPass **`include=accounts`** on **Get OAuth App** to inline the first page (up to 100) of the accounts collection above. A `meta.hasMore` of true alongside a null `meta.nextCursor` means the app has more accounts than fit inline; page them at **List OAuth App Accounts**.\n\nPass **`include=investigation`** on **Get OAuth App** to also return the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps): its status, timestamps, overall risk narrative, and per-dimension assessments (vendor trust, scope risk, blast radius, and app behavior). Reading it never starts a new run, and it is null whenever there is no investigation to return. This include requires permission to read investigation data in addition to app access; a token without it gets a 403 rather than a response with the field quietly missing, so a null always means \"there is nothing to show\" and never \"not allowed to see it\". The narrative fields are written by the OAuth Remediation Agent and quote app metadata the app publisher controls, so treat them as untrusted text.\n\nPass **`include=*`** to return both.\n\n**Revoking One Account's Grant**\n\n**[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** revokes a single account's grant of an app at the provider and returns that account's updated entry from the accounts collection.\n\nThis is a one-off action, not a policy. It leaves the app's `remediation` untouched, so if the account grants the app again, Material won't revoke it automatically. Use `remediation` when you want that. The whole grant goes rather than selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing.\n\nA 403 here means [OAuth app remediation](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-remediation-responses) is turned off for that account, which is a per-account setting rather than a property of your token.\n\n**Updating an App**\n\nAn app's identity, scopes, brand, and grant history are observed from Google and read-only. The two fields you can set are `classification` and `remediation`; only fields present in the body change.\n\n`remediation` is a **standing policy, not a one-shot action**. `[\"revoke\"]` or `[\"revoke\", \"notify\"]` tells Material to keep revoking this app's grants, including for accounts that grant it later. The work is asynchronous, so a 200 records the policy rather than confirming a revocation. `[]` removes the policy, which stops future enforcement but does not restore revoked grants. `notify` cannot be sent alone, since it means notifying the accounts whose grants were revoked.\n\nSetting `classification` resolves a staged agent recommendation that carries an `issueId` to `accepted` or `rejected` and mirrors onto the app's open investigation issues. Classification never implies a remediation: set both fields explicitly when you want both. Because remediation is a standing policy, any update to an app that already carries one, including a classification-only update, can enforce it against accounts that have granted the app since the last enforcement pass. There is no way to return an app to unclassified.\n\n**Response Structure**\n\nList responses follow the standard two-part structure: a `meta` object (with `totalCount`, `limit`, `hasMore`, and `nextCursor`) and an `items` array. **List OAuth Apps** items are OAuth app objects; **List OAuth App Accounts** items are accounts carrying the grant under `edge`, and its `meta` adds `numActive`.\n\n**Want a walkthrough?**\n\nReview [Hello World](https://docs.material.security/reference/api-v1/hello-world).\n\n**Pagination and Rate Limiting**\n\n* Material APIs use **cursor-based pagination**: pass the `nextCursor` value from one response as the `cursor` parameter in the next request.\n* The `limit` parameter controls page size and is locked into the cursor, so later pages keep the page size of the first request.\n* When `hasMore` is false and `nextCursor` is null, you've reached the last page.\n* Each page is a point-in-time window over a live inventory. Apps sort in a stable total order, but an app observed while you are paging shifts later rows, so consecutive pages can still repeat or skip one. Reconcile paged results by `clientId` when you need exactness, and re-fetch specific apps with the `clientId` filter.\n"}],"servers":[{"url":"https://{domain}","description":"Material Security instance","variables":{"domain":{"default":"your-instance.on.material.security","description":"Your Material Security instance domain"}}}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-material-client-secret","description":"Material Security API key"}},"schemas":{"UpdateOAuthAppBody":{"title":"UpdateOAuthAppBody","description":"Only provided fields are changed. An empty body is a no-op.","type":"object","properties":{"classification":{"description":"Commit a classification. A staged agent recommendation that carries an `issueId` resolves to `accepted` or `rejected`; the value also mirrors onto this app's open investigation issues. There is no way back to unclassified through this API.","type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},"remediation":{"description":"Replace the standing remediation policy, or send `[]` to remove it. `notify` cannot be sent without `revoke`. Revocation is queued, so a 200 means the policy was recorded, not that any token is revoked yet; removing a policy stops future enforcement but does not restore revoked grants.","type":"array","items":{"type":"string","enum":["revoke","notify"]}}}},"APIOAuthApp":{"title":"APIOAuthApp","description":"A third-party OAuth application observed holding token grants in your Google Workspace tenant. Google Workspace is the only provider covered today.","type":"object","properties":{"id":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"tenantId":{"description":"The encoded ID of the tenant the app was observed in. Format: `tnt.1.<base64>`.","type":"string"},"name":{"description":"The name Google shows on the consent screen. The app publisher chooses it, so it can impersonate a well-known vendor; use `clientId` to identify an app.","type":"string"},"clientId":{"description":"The Google OAuth client ID. This is the stable identity of the app and the value that appears in Google Workspace audit logs.","type":"string"},"isInternal":{"description":"True when the app's registered brand belongs to one of your own domains (for example an Apps Script written in-house). False when unknown.","type":"boolean"},"isLoginOnly":{"description":"True when every scope currently granted is a sign-in scope (`email`, `profile`, or `openid`), so the app can identify users but read nothing. Derived from the same observed grants as `scopes`, so it carries the same staleness, and it reads false for an app Material has never computed it for.","type":"boolean"},"scopes":{"description":"The union of scopes across the grants Material last observed as active, sorted. This can lag in both directions: a very recent grant may not appear yet, and after every account is revoked at once it can keep listing scopes no account still holds. Use the accounts collection for live per-account grant state.","type":"array","items":{"type":"string"}},"restrictedScopeTypes":{"description":"Which of Google's restricted-scope families the observed granted scopes fall into, sorted. Derived from `scopes`, so it carries the same staleness in both directions.","type":"array","items":{"type":"string","enum":["gmail","drive","google_fit","google_chat","data_portability","photos_ambient"]}},"brand":{"description":"Brand metadata, or null when Google returned no brand record.","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppBrand"},{"type":"null"}]},"classification":{"description":"The committed classification of the app, set by an admin or committed automatically by a `safe` investigation verdict. Null means unclassified, which **Explorer** > **Apps** > **OAuth** labels Unknown. The list `classification` filter matches the effective classification instead, so a filtered result can carry a value here that differs from the one you filtered on. The effective value is derivable from this payload: it is `recommendedClassification.value` while that object has `status: 'staged'`, and this field otherwise.","anyOf":[{"type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},{"type":"null"}]},"recommendedClassification":{"description":"The OAuth Remediation Agent's suggestion and how it was resolved. Null when there is no suggestion.","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppRecommendedClassification"},{"type":"null"}]},"remediation":{"description":"The standing remediation policy for this app, not a record of past actions. `[]` means none. `notify` always accompanies `revoke`, never on its own.","type":"array","items":{"type":"string","enum":["revoke","notify"]}},"keyEvents":{"$ref":"#/components/schemas/APIOAuthAppKeyEvents"},"investigation":{"description":"Populated only when `include=investigation`. Null when there is no investigation available (never investigated, or a stale pointer whose run has been purged). Requesting the include without permission to read investigation data returns 403, so a null here never means \"not allowed to see it\".","anyOf":[{"$ref":"#/components/schemas/APIOAuthAppInvestigation"},{"type":"null"}]},"accounts":{"description":"Populated only when `include=accounts`, with the first page of the accounts edge. `meta.hasMore: true` with a null `meta.nextCursor` means there are more; page them at `GET /oauth-apps/{id}/accounts`.","$ref":"#/components/schemas/APIOAuthAppAccountCollection"}},"required":["id","tenantId","name","clientId","isInternal","isLoginOnly","scopes","restrictedScopeTypes","brand","classification","recommendedClassification","remediation","keyEvents"]},"APIOAuthAppBrand":{"title":"APIOAuthAppBrand","description":"Brand metadata registered with Google for this OAuth client. Null when Google returned no brand record.","type":"object","properties":{"verified":{"description":"Whether Google's brand verification returned a verified brand record for this app. Unverified apps can still set any of the fields below, so treat them as attacker-controllable input.","type":"boolean"},"displayName":{"description":"Brand display name; the verified value when present. Null if the brand has no name.","anyOf":[{"type":"string"},{"type":"null"}]},"supportEmail":{"description":"Brand support email; the verified value when present. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]},"homePageUrl":{"description":"Brand home page URL; the verified value when present. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"description":"URL of the app's consent-screen icon. Null if unavailable.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["verified","displayName","supportEmail","homePageUrl","iconUrl"]},"APIOAuthAppRecommendedClassification":{"title":"APIOAuthAppRecommendedClassification","description":"The OAuth Remediation Agent's suggested classification, mirrored from the issue that carries it. Null when no issue currently sources one. The investigation of a newly granted app stages whatever it concludes, so `safe` is reachable here; a later re-investigation that concludes `safe` stages nothing, instead committing `classification` directly when the app is unclassified or was system-classified, and dropping the verdict otherwise.","type":"object","properties":{"value":{"description":"The classification Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) recommends.","type":"string","enum":["safe","suspicious","overprivileged","unnecessary","malicious"]},"status":{"description":"`staged` means the recommendation is awaiting a human decision and is what **Explorer** > **Apps** > **OAuth** displays in place of `classification`. `accepted` and `rejected` record how an admin resolved it; `rejected` leaves `classification` untouched, so the disagreement stays readable.","type":"string","enum":["staged","accepted","rejected"]},"issueId":{"description":"The audit issue that carries this recommendation. Null when no issue exists. Reading it requires issue permissions.","anyOf":[{"type":"string"},{"type":"null"}]}},"required":["value","status","issueId"]},"APIOAuthAppKeyEvents":{"title":"APIOAuthAppKeyEvents","type":"object","properties":{"created":{"description":"When Material created this app record, which is the same sync that first observed a grant, so it normally matches `firstObserved`.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"firstObserved":{"description":"The earliest grant of this app Material has observed. Google does not report when a grant was actually made, so for apps already present when Material first synced the tenant this is the first-sync time rather than the original grant date. Reflects grants that still exist: if the earliest granting account is deleted, this moves forward.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"lastNewGrant":{"description":"When this app was most recently granted by an account that did not already grant it. An existing account re-granting after revoking does not advance this; the per-account `lastGrantedAt` on the accounts collection does.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"classificationChanged":{"description":"When `classification` was last written, including a re-save of the same value. Absent while the app has no classification, which also covers a classification that was later cleared.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]},"remediationChanged":{"description":"When the remediation policy was last written, including a write that set it to none. Present alongside an empty `remediation` whenever a classification was saved without one.","type":"object","properties":{"at":{"description":"ISO 8601 timestamp of the event","type":"string","format":"date-time"}},"required":["at"]}},"required":["created","firstObserved","lastNewGrant"]},"APIOAuthAppInvestigation":{"title":"APIOAuthAppInvestigation","description":"The most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) for this app. Reading this never starts a new run.","type":"object","properties":{"status":{"description":"The state of the run: `running`, `completed`, or `failed`. Check this rather than the text fields to tell whether a run has finished.","type":"string","enum":["running","completed","failed"]},"startedAt":{"description":"When the run started, in ISO 8601 format. For runs reconciled from an existing issue this is taken from the issue rather than the run, so it can fall after `completedAt`; treat the pair as bounds rather than a duration.","type":"string","format":"date-time"},"completedAt":{"description":"When the run finished, in ISO 8601 format. Null while running.","anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"riskAssessment":{"description":"The overall risk narrative, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). Empty until the run completes. It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"},"assessments":{"$ref":"#/components/schemas/APIOAuthAppInvestigationAssessments"}},"required":["status","startedAt","completedAt","riskAssessment","assessments"]},"APIOAuthAppInvestigationAssessments":{"title":"APIOAuthAppInvestigationAssessments","description":"Per-dimension verdicts. Each is null when the run produced no assessment for that dimension.","type":"object","properties":{"vendorTrust":{"anyOf":[{"$ref":"#/components/schemas/APIVendorTrustAssessment"},{"type":"null"}]},"scopeRisk":{"anyOf":[{"$ref":"#/components/schemas/APIScopeRiskAssessment"},{"type":"null"}]},"blastRadius":{"anyOf":[{"$ref":"#/components/schemas/APIBlastRadiusAssessment"},{"type":"null"}]},"appBehavior":{"anyOf":[{"$ref":"#/components/schemas/APIAppBehaviorAssessment"},{"type":"null"}]}},"required":["vendorTrust","scopeRisk","blastRadius","appBehavior"]},"APIVendorTrustAssessment":{"title":"APIVendorTrustAssessment","description":"How well-known and reputable the app publisher is.","type":"object","properties":{"level":{"description":"How much the app publisher is trusted: `very_high`, `high`, `medium`, `low`, or `distrust`.","type":"string","enum":["very_high","high","medium","low","distrust"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIScopeRiskAssessment":{"title":"APIScopeRiskAssessment","description":"How much damage the scopes this app holds would allow.","type":"object","properties":{"level":{"description":"How dangerous the granted scopes are: `very_low`, `low`, `medium`, `high`, or `critical`.","type":"string","enum":["very_low","low","medium","high","critical"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIBlastRadiusAssessment":{"title":"APIBlastRadiusAssessment","description":"How much of the tenant is exposed through this app.","type":"object","properties":{"level":{"description":"How widely the app is granted across your accounts: `small`, `moderate`, or `large`.","type":"string","enum":["small","moderate","large"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIAppBehaviorAssessment":{"title":"APIAppBehaviorAssessment","description":"How the app's observed API activity reads.","type":"object","properties":{"level":{"description":"What the observed behavior looks like: `benign`, `unclear`, or `suspicious`.","type":"string","enum":["benign","unclear","suspicious"]},"summary":{"description":"One-paragraph finding for this dimension, written by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps). It quotes app metadata the app publisher controls, so treat it as untrusted text and never as markup.","type":"string"}},"required":["level","summary"]},"APIOAuthAppAccountCollection":{"title":"APIOAuthAppAccountCollection","type":"object","properties":{"meta":{"type":"object","properties":{"totalCount":{"description":"The total number of items matching the query.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"limit":{"description":"The page size used for this response.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"hasMore":{"description":"True if more items exist beyond the current page; false otherwise.","type":"boolean"},"nextCursor":{"description":"The cursor to fetch the next page of results. Pass this value to the `cursor` query parameter. Null if no more pages exist.","anyOf":[{"type":"string"},{"type":"null"}]},"numActive":{"description":"How many accounts currently grant this app, counted live for this request. Counted across every account, not just this page, and unaffected by the filters, so it stays comparable as you filter. The `hasActiveAccounts` filter and the `activeAccounts` sort on the app list read a cached count instead, so this is the figure to trust when the two disagree.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["numActive"]},"items":{"type":"array","items":{"$ref":"#/components/schemas/APIOAuthAppAccount"}}},"required":["meta","items"]},"APIOAuthAppAccount":{"title":"APIOAuthAppAccount","description":"An account that has granted this OAuth app, with the grant itself under `edge`. An account appears here once it has ever granted the app, whether or not it still does. Membership is decided by the grant, not by the account directory, so this is not a filtered view of `/accounts` and does not apply that resource's rules about which accounts are listed. Every account here is a licensed member of the tenant whose grant Material recorded.","type":"object","properties":{"id":{"description":"The encoded account ID. Format: `acct.1.<base64>`.","type":"string"},"email":{"description":"Null when Material holds no email address for the account, or when your token cannot read the account. The latter means the account has since moved to a domain your token cannot reach; the grant it made while in this one still lists, so the app's reach is not understated. Narrow with `providerStatus` to drop those rows.","anyOf":[{"type":"string"},{"type":"null"}]},"displayName":{"description":"Null when Material holds no display name for the account, or when your token cannot read the account, on the same terms as `email`.","anyOf":[{"type":"string"},{"type":"null"}]},"edge":{"$ref":"#/components/schemas/APIOAuthAppGrant"}},"required":["id","email","displayName","edge"]},"APIOAuthAppGrant":{"title":"APIOAuthAppGrant","description":"This account's grant of this app. Describes the pair, never the account on its own.","type":"object","properties":{"active":{"description":"Whether this account currently grants the app. False covers every way a grant can end: the account revoked it, Material revoked it, or the account was suspended or deleted. Reflects the last completed sync.","type":"boolean"},"firstGrantedAt":{"description":"When Material first observed this account granting the app, in ISO 8601 format. Google does not report when a grant was actually made, so for a grant that already existed when Material first synced the tenant this is the first-sync time rather than the original grant date. The app-level `keyEvents.firstObserved` is the earliest of these across every account.","type":"string","format":"date-time"},"lastGrantedAt":{"description":"When Material most recently observed this account granting the app, in ISO 8601 format. Equal to `firstGrantedAt` unless the account granted the app again after an earlier grant had ended. Not the per-account form of `keyEvents.lastNewGrant`: that one moves only when an account that did not already grant the app grants it, so a re-grant advances this and leaves it alone.","type":"string","format":"date-time"},"scopes":{"description":"The scopes in this account's own grant, which can be narrower than the app-level `scopes` union.","type":"array","items":{"type":"string"}}},"required":["active","firstGrantedAt","lastGrantedAt","scopes"]},"ValidationErrorResponse":{"title":"Validation Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"},"validationFailures":{"description":"List of individual validation failures when request parameters fail schema validation.","type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}},"required":["error"]},"ErrorResponse":{"title":"Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"}},"required":["error"]}}},"paths":{"/api/v1/oauth-apps/{id}":{"patch":{"operationId":"patchOauthApp","summary":"Update OAuth App","description":"Updates an OAuth app's classification and standing remediation policy. All other fields are read-only. Only provided fields change. A remediation containing `revoke` queues revocation of the app's token grants asynchronously, so a 200 records the policy rather than confirming any token is revoked; `[]` removes the policy. Setting a classification also resolves a staged agent recommendation that carries an `issueId`, and mirrors onto the app's open investigation issues.","tags":["OAuth Apps"],"parameters":[{"in":"path","name":"id","schema":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"required":true,"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOAuthAppBody"}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIOAuthApp"}}}},"400":{"description":"Bad request: invalid parameters or request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"401":{"description":"Unauthorized: missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden: the API key doesn't have permission to perform this action","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not found: the requested resource doesn't exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests: the client has exceeded a rate limit and should retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## List OAuth App Accounts

> Retrieves the paginated list of accounts that have granted this OAuth app, ordered by when each account first granted it, newest first. By default every account that ever granted it is listed, including ones whose grants have ended; \`active\` narrows by grant state and \`providerStatus\` by account state, and the two are independent. Membership is decided by the grant rather than by the account directory, so this is not a filtered view of \`/accounts\`. Each item carries the scopes of that account's own grant, which can be narrower than the app-level union, and \`meta.numActive\` reports how many accounts currently grant the app regardless of the filters.

```json
{"openapi":"3.1.0","info":{"title":"Material Security API","version":"v1"},"tags":[{"name":"OAuth Apps","description":"**Overview**\n\nThe OAuth Apps API provides programmatic access to the third-party applications that hold [OAuth token grants](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) in your tenant. Google Workspace is the only provider covered today, so every app returned is a Google OAuth client. Each app exposes its Google client ID, the scopes currently granted across your accounts, the restricted-scope families those scopes fall into, brand metadata and verification status, its committed classification, any classification recommended by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps), and the standing remediation policy assigned to it.\n\nFive endpoints make up the OAuth Apps API:\n\n* **[List OAuth Apps](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps)** (`GET /api/v1/oauth-apps`): filter, sort, and page through OAuth apps\n* **[Get OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id)** (`GET /api/v1/oauth-apps/{id}`): retrieve a single OAuth app by its encoded ID\n* **[List OAuth App Accounts](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id-accounts)** (`GET /api/v1/oauth-apps/{id}/accounts`): page the accounts that have granted one app\n* **[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** (`POST /api/v1/oauth-apps/{id}/accounts/{accountId}/revoke`): revoke one account's grant of an app\n* **[Update OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#patch-api-v1-oauth-apps-id)** (`PATCH /api/v1/oauth-apps/{id}`): set the classification and the remediation policy\n\n**Filtering**\n\nList accepts `search` (substring match over app name or client ID), `clientId` (exact match, the way to resolve a client ID from a Google Workspace audit log), `classification`, `remediation`, `hasActiveAccounts`, `verified`, `internal`, `loginOnly`, `restrictedScopeType`, and `tenant` (encoded tenant IDs; always intersected with the tenants your token can reach, so an unreachable ID matches nothing). Comma-separated values match any of the listed values within a single filter, and every filter you add narrows the result set further. By default the list returns every OAuth app ever observed, including apps whose grants have all been revoked; pass `hasActiveAccounts=true` to narrow to apps still in use.\n\nThe console labels an app with no classification \"Unknown\", which is `classification: null` here and the `none` filter value. For what each [classification](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-detections-and-classification) means, see OAuth Detections and Classification.\n\nThe `classification` filter matches the **effective** classification, the same value **Explorer** > **Apps** > **OAuth** shows: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`. There is no committed-only filter mode: an app whose committed value differs from a staged recommendation will not match a `classification` query for that committed value at all. To work with committed values, list without the `classification` filter and compare the `classification` field yourself.\n\n**Sorting**\n\nSort with `?sort=<field>:asc|desc`. Supported fields: `name`, `firstObserved`, `lastNewGrant`, and `activeAccounts`. One field at a time; the default is `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh.\n\n**Accounts**\n\nAn OAuth app is only as dangerous as the accounts that granted it, so each app has an accounts collection: **List OAuth App Accounts** pages it, and `include=accounts` on **Get OAuth App** inlines its first page. Each item is the account (encoded ID, email, display name) plus an `edge` object describing that one account's grant: whether it is still `active`, when it was first and most recently granted, and the scopes in that grant, which can be narrower than the app-level `scopes` union.\n\nBy default every account that has ever granted the app is listed, revoked grants included, ordered by when each account first granted it, newest first. Pass `active=true` for the accounts that currently grant it, or `active=false` for the ones that no longer do; `providerStatus` narrows by the account's own state instead, and `providerStatus=active` is the population the console's Accounts tab shows for the app. `meta.numActive` counts the currently granting accounts across the whole app and does not move when you filter, so it stays comparable against `meta.totalCount`. An `edge.active` of false covers every way a grant ends: the user revoked it, Material revoked it, or the account was suspended or deleted.\n\n**The `include` Parameter**\n\nPass **`include=accounts`** on **Get OAuth App** to inline the first page (up to 100) of the accounts collection above. A `meta.hasMore` of true alongside a null `meta.nextCursor` means the app has more accounts than fit inline; page them at **List OAuth App Accounts**.\n\nPass **`include=investigation`** on **Get OAuth App** to also return the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps): its status, timestamps, overall risk narrative, and per-dimension assessments (vendor trust, scope risk, blast radius, and app behavior). Reading it never starts a new run, and it is null whenever there is no investigation to return. This include requires permission to read investigation data in addition to app access; a token without it gets a 403 rather than a response with the field quietly missing, so a null always means \"there is nothing to show\" and never \"not allowed to see it\". The narrative fields are written by the OAuth Remediation Agent and quote app metadata the app publisher controls, so treat them as untrusted text.\n\nPass **`include=*`** to return both.\n\n**Revoking One Account's Grant**\n\n**[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** revokes a single account's grant of an app at the provider and returns that account's updated entry from the accounts collection.\n\nThis is a one-off action, not a policy. It leaves the app's `remediation` untouched, so if the account grants the app again, Material won't revoke it automatically. Use `remediation` when you want that. The whole grant goes rather than selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing.\n\nA 403 here means [OAuth app remediation](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-remediation-responses) is turned off for that account, which is a per-account setting rather than a property of your token.\n\n**Updating an App**\n\nAn app's identity, scopes, brand, and grant history are observed from Google and read-only. The two fields you can set are `classification` and `remediation`; only fields present in the body change.\n\n`remediation` is a **standing policy, not a one-shot action**. `[\"revoke\"]` or `[\"revoke\", \"notify\"]` tells Material to keep revoking this app's grants, including for accounts that grant it later. The work is asynchronous, so a 200 records the policy rather than confirming a revocation. `[]` removes the policy, which stops future enforcement but does not restore revoked grants. `notify` cannot be sent alone, since it means notifying the accounts whose grants were revoked.\n\nSetting `classification` resolves a staged agent recommendation that carries an `issueId` to `accepted` or `rejected` and mirrors onto the app's open investigation issues. Classification never implies a remediation: set both fields explicitly when you want both. Because remediation is a standing policy, any update to an app that already carries one, including a classification-only update, can enforce it against accounts that have granted the app since the last enforcement pass. There is no way to return an app to unclassified.\n\n**Response Structure**\n\nList responses follow the standard two-part structure: a `meta` object (with `totalCount`, `limit`, `hasMore`, and `nextCursor`) and an `items` array. **List OAuth Apps** items are OAuth app objects; **List OAuth App Accounts** items are accounts carrying the grant under `edge`, and its `meta` adds `numActive`.\n\n**Want a walkthrough?**\n\nReview [Hello World](https://docs.material.security/reference/api-v1/hello-world).\n\n**Pagination and Rate Limiting**\n\n* Material APIs use **cursor-based pagination**: pass the `nextCursor` value from one response as the `cursor` parameter in the next request.\n* The `limit` parameter controls page size and is locked into the cursor, so later pages keep the page size of the first request.\n* When `hasMore` is false and `nextCursor` is null, you've reached the last page.\n* Each page is a point-in-time window over a live inventory. Apps sort in a stable total order, but an app observed while you are paging shifts later rows, so consecutive pages can still repeat or skip one. Reconcile paged results by `clientId` when you need exactness, and re-fetch specific apps with the `clientId` filter.\n"}],"servers":[{"url":"https://{domain}","description":"Material Security instance","variables":{"domain":{"default":"your-instance.on.material.security","description":"Your Material Security instance domain"}}}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-material-client-secret","description":"Material Security API key"}},"schemas":{"APIOAuthAppAccountCollection":{"title":"APIOAuthAppAccountCollection","type":"object","properties":{"meta":{"type":"object","properties":{"totalCount":{"description":"The total number of items matching the query.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"limit":{"description":"The page size used for this response.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"hasMore":{"description":"True if more items exist beyond the current page; false otherwise.","type":"boolean"},"nextCursor":{"description":"The cursor to fetch the next page of results. Pass this value to the `cursor` query parameter. Null if no more pages exist.","anyOf":[{"type":"string"},{"type":"null"}]},"numActive":{"description":"How many accounts currently grant this app, counted live for this request. Counted across every account, not just this page, and unaffected by the filters, so it stays comparable as you filter. The `hasActiveAccounts` filter and the `activeAccounts` sort on the app list read a cached count instead, so this is the figure to trust when the two disagree.","type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["numActive"]},"items":{"type":"array","items":{"$ref":"#/components/schemas/APIOAuthAppAccount"}}},"required":["meta","items"]},"APIOAuthAppAccount":{"title":"APIOAuthAppAccount","description":"An account that has granted this OAuth app, with the grant itself under `edge`. An account appears here once it has ever granted the app, whether or not it still does. Membership is decided by the grant, not by the account directory, so this is not a filtered view of `/accounts` and does not apply that resource's rules about which accounts are listed. Every account here is a licensed member of the tenant whose grant Material recorded.","type":"object","properties":{"id":{"description":"The encoded account ID. Format: `acct.1.<base64>`.","type":"string"},"email":{"description":"Null when Material holds no email address for the account, or when your token cannot read the account. The latter means the account has since moved to a domain your token cannot reach; the grant it made while in this one still lists, so the app's reach is not understated. Narrow with `providerStatus` to drop those rows.","anyOf":[{"type":"string"},{"type":"null"}]},"displayName":{"description":"Null when Material holds no display name for the account, or when your token cannot read the account, on the same terms as `email`.","anyOf":[{"type":"string"},{"type":"null"}]},"edge":{"$ref":"#/components/schemas/APIOAuthAppGrant"}},"required":["id","email","displayName","edge"]},"APIOAuthAppGrant":{"title":"APIOAuthAppGrant","description":"This account's grant of this app. Describes the pair, never the account on its own.","type":"object","properties":{"active":{"description":"Whether this account currently grants the app. False covers every way a grant can end: the account revoked it, Material revoked it, or the account was suspended or deleted. Reflects the last completed sync.","type":"boolean"},"firstGrantedAt":{"description":"When Material first observed this account granting the app, in ISO 8601 format. Google does not report when a grant was actually made, so for a grant that already existed when Material first synced the tenant this is the first-sync time rather than the original grant date. The app-level `keyEvents.firstObserved` is the earliest of these across every account.","type":"string","format":"date-time"},"lastGrantedAt":{"description":"When Material most recently observed this account granting the app, in ISO 8601 format. Equal to `firstGrantedAt` unless the account granted the app again after an earlier grant had ended. Not the per-account form of `keyEvents.lastNewGrant`: that one moves only when an account that did not already grant the app grants it, so a re-grant advances this and leaves it alone.","type":"string","format":"date-time"},"scopes":{"description":"The scopes in this account's own grant, which can be narrower than the app-level `scopes` union.","type":"array","items":{"type":"string"}}},"required":["active","firstGrantedAt","lastGrantedAt","scopes"]},"ValidationErrorResponse":{"title":"Validation Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"},"validationFailures":{"description":"List of individual validation failures when request parameters fail schema validation.","type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}},"required":["error"]},"ErrorResponse":{"title":"Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"}},"required":["error"]}}},"paths":{"/api/v1/oauth-apps/{id}/accounts":{"get":{"operationId":"listOauthAppAccounts","summary":"List OAuth App Accounts","description":"Retrieves the paginated list of accounts that have granted this OAuth app, ordered by when each account first granted it, newest first. By default every account that ever granted it is listed, including ones whose grants have ended; `active` narrows by grant state and `providerStatus` by account state, and the two are independent. Membership is decided by the grant rather than by the account directory, so this is not a filtered view of `/accounts`. Each item carries the scopes of that account's own grant, which can be narrower than the app-level union, and `meta.numActive` reports how many accounts currently grant the app regardless of the filters.","tags":["OAuth Apps"],"parameters":[{"in":"path","name":"id","schema":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"required":true,"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`."},{"in":"query","name":"cursor","schema":{"description":"The pagination cursor returned in a previous response. Pass this value to retrieve the next page of results.","type":"string"},"description":"The pagination cursor returned in a previous response. Pass this value to retrieve the next page of results."},{"in":"query","name":"limit","schema":{"description":"The maximum number of items to return per page.","type":"integer"},"description":"The maximum number of items to return per page."},{"in":"query","name":"active","schema":{"description":"Filter to accounts that currently grant the app (`true`) or that no longer do (`false`). Omit to list every account that ever granted it.","type":"boolean"},"description":"Filter to accounts that currently grant the app (`true`) or that no longer do (`false`). Omit to list every account that ever granted it."},{"in":"query","name":"providerStatus","schema":{"description":"Filter by the granting account's current state at your provider: `active`, `suspended`, `archived`, or `deleted`. Independent of the grant-level `active` filter: an active account can hold an ended grant, and a suspended one can still hold a live grant. Combine the two to narrow on both.","type":"string","enum":["active","suspended","archived","deleted"]},"description":"Filter by the granting account's current state at your provider: `active`, `suspended`, `archived`, or `deleted`. Independent of the grant-level `active` filter: an active account can hold an ended grant, and a suspended one can still hold a live grant. Combine the two to narrow on both."}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIOAuthAppAccountCollection"}}}},"400":{"description":"Bad request: invalid parameters or request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"401":{"description":"Unauthorized: missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden: the API key doesn't have permission to perform this action","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not found: the requested resource doesn't exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests: the client has exceeded a rate limit and should retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## Revoke OAuth App Account Grant

> Revokes one account's grant of this OAuth app at the provider, and returns the updated entry from the app's accounts collection. This is a one-off action, not a policy: it leaves the app's \`remediation\` untouched, and if the account grants the app again it will not be revoked automatically. Use \`remediation\` for that. The whole grant goes, not selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing. Returns 403 if OAuth app remediation is disabled for the account, which is a per-account setting rather than a property of your token.

```json
{"openapi":"3.1.0","info":{"title":"Material Security API","version":"v1"},"tags":[{"name":"OAuth Apps","description":"**Overview**\n\nThe OAuth Apps API provides programmatic access to the third-party applications that hold [OAuth token grants](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps) in your tenant. Google Workspace is the only provider covered today, so every app returned is a Google OAuth client. Each app exposes its Google client ID, the scopes currently granted across your accounts, the restricted-scope families those scopes fall into, brand metadata and verification status, its committed classification, any classification recommended by Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps), and the standing remediation policy assigned to it.\n\nFive endpoints make up the OAuth Apps API:\n\n* **[List OAuth Apps](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps)** (`GET /api/v1/oauth-apps`): filter, sort, and page through OAuth apps\n* **[Get OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id)** (`GET /api/v1/oauth-apps/{id}`): retrieve a single OAuth app by its encoded ID\n* **[List OAuth App Accounts](https://docs.material.security/reference/api-v1/oauth-apps#get-api-v1-oauth-apps-id-accounts)** (`GET /api/v1/oauth-apps/{id}/accounts`): page the accounts that have granted one app\n* **[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** (`POST /api/v1/oauth-apps/{id}/accounts/{accountId}/revoke`): revoke one account's grant of an app\n* **[Update OAuth App](https://docs.material.security/reference/api-v1/oauth-apps#patch-api-v1-oauth-apps-id)** (`PATCH /api/v1/oauth-apps/{id}`): set the classification and the remediation policy\n\n**Filtering**\n\nList accepts `search` (substring match over app name or client ID), `clientId` (exact match, the way to resolve a client ID from a Google Workspace audit log), `classification`, `remediation`, `hasActiveAccounts`, `verified`, `internal`, `loginOnly`, `restrictedScopeType`, and `tenant` (encoded tenant IDs; always intersected with the tenants your token can reach, so an unreachable ID matches nothing). Comma-separated values match any of the listed values within a single filter, and every filter you add narrows the result set further. By default the list returns every OAuth app ever observed, including apps whose grants have all been revoked; pass `hasActiveAccounts=true` to narrow to apps still in use.\n\nThe console labels an app with no classification \"Unknown\", which is `classification: null` here and the `none` filter value. For what each [classification](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-detections-and-classification) means, see OAuth Detections and Classification.\n\nThe `classification` filter matches the **effective** classification, the same value **Explorer** > **Apps** > **OAuth** shows: while the OAuth Remediation Agent has a recommendation staged, that recommendation is matched in place of the committed value. A matching app can therefore come back with `classification: null` (or a different committed value) and the matched value in `recommendedClassification`. There is no committed-only filter mode: an app whose committed value differs from a staged recommendation will not match a `classification` query for that committed value at all. To work with committed values, list without the `classification` filter and compare the `classification` field yourself.\n\n**Sorting**\n\nSort with `?sort=<field>:asc|desc`. Supported fields: `name`, `firstObserved`, `lastNewGrant`, and `activeAccounts`. One field at a time; the default is `firstObserved:desc`. `activeAccounts` orders by a cached count that a tenant-wide revoke does not immediately refresh.\n\n**Accounts**\n\nAn OAuth app is only as dangerous as the accounts that granted it, so each app has an accounts collection: **List OAuth App Accounts** pages it, and `include=accounts` on **Get OAuth App** inlines its first page. Each item is the account (encoded ID, email, display name) plus an `edge` object describing that one account's grant: whether it is still `active`, when it was first and most recently granted, and the scopes in that grant, which can be narrower than the app-level `scopes` union.\n\nBy default every account that has ever granted the app is listed, revoked grants included, ordered by when each account first granted it, newest first. Pass `active=true` for the accounts that currently grant it, or `active=false` for the ones that no longer do; `providerStatus` narrows by the account's own state instead, and `providerStatus=active` is the population the console's Accounts tab shows for the app. `meta.numActive` counts the currently granting accounts across the whole app and does not move when you filter, so it stays comparable against `meta.totalCount`. An `edge.active` of false covers every way a grant ends: the user revoked it, Material revoked it, or the account was suspended or deleted.\n\n**The `include` Parameter**\n\nPass **`include=accounts`** on **Get OAuth App** to inline the first page (up to 100) of the accounts collection above. A `meta.hasMore` of true alongside a null `meta.nextCursor` means the app has more accounts than fit inline; page them at **List OAuth App Accounts**.\n\nPass **`include=investigation`** on **Get OAuth App** to also return the most recent run of Material's [OAuth Remediation Agent](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps): its status, timestamps, overall risk narrative, and per-dimension assessments (vendor trust, scope risk, blast radius, and app behavior). Reading it never starts a new run, and it is null whenever there is no investigation to return. This include requires permission to read investigation data in addition to app access; a token without it gets a 403 rather than a response with the field quietly missing, so a null always means \"there is nothing to show\" and never \"not allowed to see it\". The narrative fields are written by the OAuth Remediation Agent and quote app metadata the app publisher controls, so treat them as untrusted text.\n\nPass **`include=*`** to return both.\n\n**Revoking One Account's Grant**\n\n**[Revoke OAuth App Account Grant](https://docs.material.security/reference/api-v1/oauth-apps#post-api-v1-oauth-apps-id-accounts-accountid-revoke)** revokes a single account's grant of an app at the provider and returns that account's updated entry from the accounts collection.\n\nThis is a one-off action, not a policy. It leaves the app's `remediation` untouched, so if the account grants the app again, Material won't revoke it automatically. Use `remediation` when you want that. The whole grant goes rather than selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing.\n\nA 403 here means [OAuth app remediation](https://docs.material.security/learn-more/risk-areas/malicious-oauth-apps/oauth-remediation-responses) is turned off for that account, which is a per-account setting rather than a property of your token.\n\n**Updating an App**\n\nAn app's identity, scopes, brand, and grant history are observed from Google and read-only. The two fields you can set are `classification` and `remediation`; only fields present in the body change.\n\n`remediation` is a **standing policy, not a one-shot action**. `[\"revoke\"]` or `[\"revoke\", \"notify\"]` tells Material to keep revoking this app's grants, including for accounts that grant it later. The work is asynchronous, so a 200 records the policy rather than confirming a revocation. `[]` removes the policy, which stops future enforcement but does not restore revoked grants. `notify` cannot be sent alone, since it means notifying the accounts whose grants were revoked.\n\nSetting `classification` resolves a staged agent recommendation that carries an `issueId` to `accepted` or `rejected` and mirrors onto the app's open investigation issues. Classification never implies a remediation: set both fields explicitly when you want both. Because remediation is a standing policy, any update to an app that already carries one, including a classification-only update, can enforce it against accounts that have granted the app since the last enforcement pass. There is no way to return an app to unclassified.\n\n**Response Structure**\n\nList responses follow the standard two-part structure: a `meta` object (with `totalCount`, `limit`, `hasMore`, and `nextCursor`) and an `items` array. **List OAuth Apps** items are OAuth app objects; **List OAuth App Accounts** items are accounts carrying the grant under `edge`, and its `meta` adds `numActive`.\n\n**Want a walkthrough?**\n\nReview [Hello World](https://docs.material.security/reference/api-v1/hello-world).\n\n**Pagination and Rate Limiting**\n\n* Material APIs use **cursor-based pagination**: pass the `nextCursor` value from one response as the `cursor` parameter in the next request.\n* The `limit` parameter controls page size and is locked into the cursor, so later pages keep the page size of the first request.\n* When `hasMore` is false and `nextCursor` is null, you've reached the last page.\n* Each page is a point-in-time window over a live inventory. Apps sort in a stable total order, but an app observed while you are paging shifts later rows, so consecutive pages can still repeat or skip one. Reconcile paged results by `clientId` when you need exactness, and re-fetch specific apps with the `clientId` filter.\n"}],"servers":[{"url":"https://{domain}","description":"Material Security instance","variables":{"domain":{"default":"your-instance.on.material.security","description":"Your Material Security instance domain"}}}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-material-client-secret","description":"Material Security API key"}},"schemas":{"APIOAuthAppAccount":{"title":"APIOAuthAppAccount","description":"An account that has granted this OAuth app, with the grant itself under `edge`. An account appears here once it has ever granted the app, whether or not it still does. Membership is decided by the grant, not by the account directory, so this is not a filtered view of `/accounts` and does not apply that resource's rules about which accounts are listed. Every account here is a licensed member of the tenant whose grant Material recorded.","type":"object","properties":{"id":{"description":"The encoded account ID. Format: `acct.1.<base64>`.","type":"string"},"email":{"description":"Null when Material holds no email address for the account, or when your token cannot read the account. The latter means the account has since moved to a domain your token cannot reach; the grant it made while in this one still lists, so the app's reach is not understated. Narrow with `providerStatus` to drop those rows.","anyOf":[{"type":"string"},{"type":"null"}]},"displayName":{"description":"Null when Material holds no display name for the account, or when your token cannot read the account, on the same terms as `email`.","anyOf":[{"type":"string"},{"type":"null"}]},"edge":{"$ref":"#/components/schemas/APIOAuthAppGrant"}},"required":["id","email","displayName","edge"]},"APIOAuthAppGrant":{"title":"APIOAuthAppGrant","description":"This account's grant of this app. Describes the pair, never the account on its own.","type":"object","properties":{"active":{"description":"Whether this account currently grants the app. False covers every way a grant can end: the account revoked it, Material revoked it, or the account was suspended or deleted. Reflects the last completed sync.","type":"boolean"},"firstGrantedAt":{"description":"When Material first observed this account granting the app, in ISO 8601 format. Google does not report when a grant was actually made, so for a grant that already existed when Material first synced the tenant this is the first-sync time rather than the original grant date. The app-level `keyEvents.firstObserved` is the earliest of these across every account.","type":"string","format":"date-time"},"lastGrantedAt":{"description":"When Material most recently observed this account granting the app, in ISO 8601 format. Equal to `firstGrantedAt` unless the account granted the app again after an earlier grant had ended. Not the per-account form of `keyEvents.lastNewGrant`: that one moves only when an account that did not already grant the app grants it, so a re-grant advances this and leaves it alone.","type":"string","format":"date-time"},"scopes":{"description":"The scopes in this account's own grant, which can be narrower than the app-level `scopes` union.","type":"array","items":{"type":"string"}}},"required":["active","firstGrantedAt","lastGrantedAt","scopes"]},"ValidationErrorResponse":{"title":"Validation Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"},"validationFailures":{"description":"List of individual validation failures when request parameters fail schema validation.","type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}},"required":["error"]},"ErrorResponse":{"title":"Error Response","type":"object","properties":{"error":{"description":"A human-readable description of the error.","type":"string"}},"required":["error"]}}},"paths":{"/api/v1/oauth-apps/{id}/accounts/{accountId}/revoke":{"post":{"operationId":"revokeOauthAppAccount","summary":"Revoke OAuth App Account Grant","description":"Revokes one account's grant of this OAuth app at the provider, and returns the updated entry from the app's accounts collection. This is a one-off action, not a policy: it leaves the app's `remediation` untouched, and if the account grants the app again it will not be revoked automatically. Use `remediation` for that. The whole grant goes, not selected scopes, because the provider offers no per-scope handle. Revoking a grant that has already ended succeeds and changes nothing. Returns 403 if OAuth app remediation is disabled for the account, which is a per-account setting rather than a property of your token.","tags":["OAuth Apps"],"parameters":[{"in":"path","name":"id","schema":{"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"required":true,"description":"The encoded OAuth app ID. Format: `oapp.1.<base64>`."},{"in":"path","name":"accountId","schema":{"description":"The encoded ID of the account whose grant of this app is revoked. Format: `acct.1.<base64>`.","type":"string","pattern":"^[a-z]{1,6}\\.[0-9]{1,2}\\.[A-Za-z0-9_-]+$"},"required":true,"description":"The encoded ID of the account whose grant of this app is revoked. Format: `acct.1.<base64>`."}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIOAuthAppAccount"}}}},"400":{"description":"Bad request: invalid parameters or request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"401":{"description":"Unauthorized: missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden: the API key doesn't have permission to perform this action","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not found: the requested resource doesn't exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests: the client has exceeded a rate limit and should retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.material.security/reference/api-v1/oauth-apps.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
