Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration

Run fully automated scripts with application permissions to pull Microsoft Forms data, as if it were an official Graph API — without any extra licenses required

For years, there’s been no official Microsoft Forms API, despite countless requests from admins and developers who need to automate data collection, reporting, or integration workflows. While Microsoft Graph covers most services, Forms has remained an outlier — its API exists and is used by the Forms web interface, but it’s undocumented, making it tricky to use for automation.

Historically, if you wanted automation around Microsoft Forms, you were pushed towards Power Automate, PowerApps, or Power BI. Those tools are powerful, but they often require additional licenses and can introduce costs that aren’t feasible for every project.

The approach I’m sharing here avoids that problem: it uses application permissions via an Azure App Registration. That means scripts can run fully automated in the background without user interaction, pulling data straight from the undocumented Forms API — essentially treating it like an official Graph endpoint.

This post walks you through the approach, shows the reusable PowerShell code, and highlights how you can start automating your own Forms workflows.

Create an App Registration in Azure

First, we need an Azure AD App Registration with permission to read Forms.

  1. Go to Azure Portal → App Registrations → New registration.
  2. Give it a name, register it as Accounts in this organizational directory only.
  3. Under API Permissions → Add a permission → APIs my organization uses, search for Microsoft Forms.
  4. Select Application permissions → Forms.Read.All
  5. Grant admin consent
  6. Go to Certificates & secrets → New client secret, and note the secret value.

As we’re using application permissions, this app can run completely headless — no sign-in prompts, no delegated user context. Perfect for automation.

Next, we’ll dive into the PowerShell code that makes it all work, including the reusable functions and token manager to handle authentication automatically.

Token Management

TInstead of getting a new token for every request, I created a TokenManager class in PowerShell:

  • Fetches a new token if none exists.
  • Refreshes only when the token is expired or within 60 seconds of expiry.
  • Stores the token for reuse across multiple API calls in the same session.

Some might say this is overkill for a few quick calls; but if you’re working with a form that has thousands of responses, the script could run for hours as it pages through responses. In that case, the Token Manager quietly refreshes tokens in the background so your script stays authorized the whole time — no manual intervention needed.

PowerShell
class TokenManager {
    [string]$TenantId
    [string]$ClientId
    [string]$ClientSecret
    [string]$Scope
    [string]$AccessToken
    [datetime]$Expiry

    TokenManager([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
        $this.TenantId = $TenantId
        $this.ClientId = $ClientId
        $this.ClientSecret = $ClientSecret
        $this.Scope = $Scope
    }

    [string]GetToken() {
        if (-not $this.AccessToken -or (Get-Date) -ge $this.Expiry) {
            $this.RefreshToken()
        }

        return $this.AccessToken
    }

    [void]RefreshToken() {
        $tokenEndpoint = "https://login.microsoftonline.com/$($this.TenantId)/oauth2/v2.0/token"

        $body = @{
            client_id     = $this.ClientId
            scope         = $this.Scope
            client_secret = $this.ClientSecret
            grant_type    = "client_credentials"
        }

        $tokenResponse = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $body -ContentType "application/x-www-form-urlencoded"

        $this.AccessToken = $tokenResponse.access_token

        $this.Expiry = (Get-Date).AddSeconds($tokenResponse.expires_in - 60)

        Write-Verbose "Access token refreshed. Expires $($this.Expiry)."
    }
}

Authenticated HTTP Requests

To make calling the API easier, I wrote a wrapper function that:

  • Automatically attaches the Bearer token
  • Retries on 401 errors (token expiry)
  • Supports all HTTP verbs

This means the rest of the script doesn’t need to care about authentication — it just calls Invoke-HttpRequestWithToken and gets JSON back.

PowerShell
function Invoke-HttpRequestWithToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet("GET", "POST", "PUT", "PATCH", "DELETE")]
        [string]$Method,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$Uri,

        [Parameter(Mandatory = $false)]
        [hashtable]$Headers = @{},

        [Parameter(Mandatory = $false)]
        [hashtable]$Body,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [TokenManager]$TokenManager,

        [Parameter(Mandatory = $false)]
        [int]$MaxRetries = 5
    )

    begin {
        $output = $null
        $retry = 0
        $Headers.Authorization = "Bearer $($TokenManager.GetToken())"
    }

    process {
        while ($retry -le $MaxRetries) {
            try {
                Write-Verbose "Making $Method request to $Uri (attempt $($retry+1))"

                if ($PSBoundParameters.ContainsKey("Body")) {
                    $output = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -Body ($Body | ConvertTo-Json) -ContentType "application/json"

                    return
                }
                else {
                    $output = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers

                    return
                }
            }
            catch [System.Net.WebException] {
                if ($_.Exception.Response.StatusCode -eq 401 -and $retry -lt $MaxRetries) {
                    Write-Verbose "Token expired. Refreshing token..."

                    $TokenManager.RefreshToken();

                    $retry++
                    continue
                }
                else {
                    throw "HTTP request failed: $($_.Exception.Message)"
                }
            }
        }

        throw "Maximum retries ($MaxRetries) exceeded for $Method $Uri"
    }

    end {
        return $output
    }
}

Fetching Forms + Responses

Now let’s look at the Get-MSForm* functions that actually retrieve data from Microsoft Forms.

Get-MSFormsByOwner

This function retrieves all the forms owned by a given user or group. It fetches metadata such as:

  • Row count (number of responses)
  • Form title
  • Creation and modification dates
  • Status
PowerShell
function Get-MSFormsByOwner {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$OwnerId,

        [Parameter(Mandatory = $true)]
        [ValidateSet("User", "Group")]
        [string]$OwnerType,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [TokenManager]$TokenManager
    )

    begin {
        $output = New-Object System.Collections.Generic.List[PSCustomObject]

        $ownerContext = switch ($OwnerType) {
            "User" { "users" }
            "Group" { "groups" }
        }

        $endpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms?`$select=id,status,title,createdDate,modifiedDate,ownerId,version,softDeleted,type"
    }

    process {
        try {
            Write-Verbose "Fetching forms for user $UserId..."

            $response = Invoke-HttpRequestWithToken -Method "GET" -Uri $endpoint -TokenManager $TokenManager

            foreach ($form in $response.value) {
                $formEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$($form.id)')?`$select=rowCount"

                Write-Verbose "Fetching rowCount for form '$($form.title)' ($($form.id))..."

                $formResponse = Invoke-HttpRequestWithToken -Method "GET" -Uri $formEndpoint -TokenManager $TokenManager

                $form | Add-Member -MemberType NoteProperty -Name rowCount -Value $formResponse.rowCount

                $output.Add([PSCustomObject]$form)
            }
        }
        catch {
            throw "Failed to get forms: $($_.Exception.Message)"
        }
    }

    end {
        return $output
    }
}
Get-MSFormResponse

This function retrieves a single response from a specific form, including data such as:

  • Row count (number of responses)
  • Form title
  • Creation and modification dates
  • Status
PowerShell
function Get-MSFormResponse {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$OwnerId,

        [Parameter(Mandatory = $true)]
        [ValidateSet("User", "Group")]
        [string]$OwnerType,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$FormId,

        [Parameter(Mandatory = $true)]
        [int]$ResponseId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [TokenManager]$TokenManager
    )

    begin {
        $output = $null

        $ownerContext = switch ($OwnerType) {
            "User" { "users" }
            "Group" { "groups" }
        }
    }

    process {
        $responsesEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')/responses?`$filter=id eq $ResponseId"

        try {
            Write-Verbose "Fetching response ID $ResponseId for form $FormId..."

            $response = Invoke-HttpRequestWithToken -Method "GET" -Uri $responsesEndpoint -TokenManager $TokenManager

            if ($response.value) {
                $output = [PSCustomObject]$response.value
            }
        }
        catch {
            throw "Failed to get response: $($_.Exception.Message)"
        }
    }

    end {
        return $output
    }
}
Get-MSFormResponses

This is the workhorse function. It retrieves all responses for a given form, with full support for paging.

Key features:

  • Takes -PageSize to control batch size
  • Takes -Skip to start from a given offset
  • Automatically loops through until all responses are fetched
  • Adds verbose logging so you can see progress
PowerShell
function Get-MSFormResponses {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$OwnerId,

        [Parameter(Mandatory = $true)]
        [ValidateSet("User", "Group")]
        [string]$OwnerType,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$FormId,

        [Parameter(Mandatory = $true)]
        [int]$PageSize,

        [Parameter(Mandatory = $false)]
        [int]$Skip = 0,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [TokenManager]$TokenManager
    )

    begin {
        $output = New-Object System.Collections.Generic.List[PSCustomObject]

        $ownerContext = switch ($OwnerType) {
            "User" { "users" }
            "Group" { "groups" }
        }

        $formEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')?`$select=rowCount"
        $formResponse = Invoke-HttpRequestWithToken -Uri $formEndpoint -Method "GET" -TokenManager $TokenManager
        $totalResponses = $formResponse.rowCount

        $totalToFetch = [math]::Max(0, $totalResponses - $Skip)

        $itemsFetched = 0
        $itemsToSkip = $Skip

        Write-Verbose "Form has $totalResponses responses. Starting at Skip=$Skip, PageSize=$PageSize. Total to fetch: $totalToFetch."
    }

    process {
        while ($itemsToSkip -lt $totalResponses) {
            $responsesEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')/responses?`$expand=comments&`$top=$PageSize&`$skip=$itemsToSkip&"

            try {
                $response = Invoke-HttpRequestWithToken -Method "GET" -Uri $responsesEndpoint -TokenManager $TokenManager

                if (-not $response.value -or $response.value.Count -eq 0) {
                    Write-Verbose "No more responses returned from API. Ending paging."
                    break
                }

                $response.value | ForEach-Object {
                    $output.Add([PSCustomObject]$_)
                }

                $itemsFetched += $response.value.Count
                $itemsToSkip += $response.value.Count

                Write-Verbose ("Fetched {0} responses this page; cumulative fetched {1}/{2}; overall position {3}/{4}" -f `
                        $response.value.Count, `
                        $itemsFetched, `
                        $totalToFetch, `
                        [math]::Min($itemsToSkip, $totalResponses), `
                        $totalResponses)

            }
            catch {
                throw "Failed to get responses: $($_.Exception.Message)"
            }
        }
    }

    end {
        return $output
    }
}

Usage Examples

Now that we’ve walked through the code and functions, let’s look at some practical usage scenarios. These examples show how you can start automating Microsoft Forms data collection right away.

Set Up TokenManager

Before calling any of the Get-MSForm* functions, you need to create a TokenManager instance.

This requires the three values from your App Registration in Azure AD:

  • Tenant ID → Found in your App Registration → Overview
  • Client ID → Found in your App Registration → Overview
  • Client Secret → Generated in App Registration → Certificates & secrets
PowerShell
$TenantId     = "<your tenant id>"
$ClientId     = "<your client id>"
$ClientSecret = "<your client secret>"

$TokenManager = [TokenManager]::new(
    $TenantId,
    $ClientId,
    $ClientSecret,
    "https://forms.office.com/.default"
)

# Test by fetching a token
$TokenManager.GetToken()

If the token is valid, you’ll get a long string back. You don’t normally need to call GetToken() directly — the wrapper functions handle that for you — but it’s a good sanity check that your App Registration and permissions are working.

Get All Forms by a User or Group

Retrieve a list of all forms owned by a specific user or group (identified by their Entra Object ID).

PowerShell
Get-MSFormsByOwner `
    -TenantId $TenantId `
    -OwnerId "<entra object id>" `
    -OwnerType "User"
    -TokenManager $TokenManager

This gives you an inventory of forms, complete with how many responses each has.

Get a Single Response

Fetch one response by ID for a given form.

PowerShell
Get-MSFormResponse `
    -TenantId $TenantId `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -FormId "<form id>" `
    -ResponseId 1 `
    -TokenManager $TokenManager

Useful for debugging or verifying individual submissions.

Get All Responses with Paging

Fetch every response from a form in batches.

PowerShell
Get-MSFormResponses `
    -TenantId $TenantId `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -FormId "<form id>" `
    -PageSize 100 `
    -TokenManager $TokenManager

The PageSize parameter controls how many records are fetched per API call. This will loop automatically until all responses are retrieved — perfect for large surveys with thousands of entries.

Get Responses with Paging and Skip (Offset)

Sometimes you may only want to retrieve newer responses or continue where a previous export left off. The -Skip parameter lets you offset results.

For example, if you already exported the first 500 responses, you can skip them and fetch the rest:

PowerShell
# Get responses starting from response 501
Get-MSFormResponses `
    -TenantId $TenantId `
    -OwnerId"<entra object id>" `
    -OwnerType "User" `
    -FormId "<form id>" `
    -PageSize 100 `
    -Skip 500 `
    -TokenManager $TokenManager

This way, you don’t need to re-fetch thousands of old responses — only the new ones that came in since your last run. Perfect for incremental automation jobs.

Get Responses with Paging and Skip (Offset)

Sometimes you may only want to retrieve newer responses or continue where a previous export left off. The -Skip parameter lets you offset results.

For example, if you already exported the first 500 responses, you can skip them and fetch the rest:

PowerShell
# Get responses starting from response 501
Get-MSFormResponses `
    -TenantId $TenantId `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -FormId "<form id>" `
    -PageSize 100 `
    -Skip 500 `
    -TokenManager $TokenManager

This way, you don’t need to re-fetch thousands of old responses — only the new ones that came in since your last run. Perfect for incremental automation jobs.

A Real-World Example: Getting Form Responses

To make this concrete, let’s walk through a simple example using the PowerShell functions we’ve covered.

  • Get the Form ID manually
    Open the form’s responses page in your browser. The URL will look like this:
Plaintext
https://forms.office.com/Pages/ResponsePage.aspx?id=_tLqPYCGw063v1PA4swe7lcR8JWHvpdGnz9zWKI-QV1UREFTSjMyUkM2WFlaUUJWQVpXOUgwMzlTRi4u

The long alphanumeric string after id= is the Form ID. Copy this value.

  • Get the User or Group ID from Entra ID (Azure AD)
    Use the user’s or group’s Object ID in Azure AD, which you’ll pass to the function to retrieve their forms.
  • Pull all responses with paging
PowerShell
<# Get All Responses with Paging Support #>
$responses = Get-MSFormResponses `
    -TenantId $TenantId `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -FormId "_tLqPYCGw063v1PA4swe7lcR8JWHvpdGnz9zWKI-QV1UREFTSjMyUkM2WFlaUUJWQVpXOUgwMzlTRi4u" `
    -PageSize 100 `
    -TokenManager $TokenManager

($responses.answers | ConvertFrom-Json)

This simple example demonstrates how easy it is to retrieve form responses programmatically. On the left, you see the standard Microsoft Forms responses page; on the right, the PowerShell output mirrors the same data in a structured format. While this example uses a single form and a single user, it proves the concept: with the token manager and paging support, you can scale this to large forms, multiple users, or even automated nightly data pulls.

Real-World Automation Scenarios

Once you can reliably fetch Forms data via PowerShell, you’re no longer locked into viewing results only in the web UI or exporting them manually. A few examples of how you might use this:

  • Exporting to CSV for Reporting
    Run the script nightly and dump responses into a CSV file that can be shared, archived, or imported into other systems. Perfect for lightweight reporting without needing Power BI licenses.
  • Saving to a Database
    Pipe responses directly into SQL Server, Azure SQL, or even a simple SQLite database. This gives you historical tracking, joins with other datasets, and more advanced analytics.
  • Integrating with a Ticketing System
    Imagine using Forms as an intake system for support or change requests. With automation, each new response could automatically create a Jira, ServiceNow, or Zendesk ticket — no manual copying and pasting required.
  • Triggering Workflows
    Hook the script into a CI/CD pipeline or an Azure Automation runbook so that responses can trigger downstream actions (e.g., provisioning resources, adding users, kicking off approvals).

In short, once Forms data becomes API-accessible, it can plug into almost anything.

Final Thoughts

While Microsoft hasn’t yet provided an official Graph API for Forms, this approach fills that gap by giving you a reliable way to query forms, fetch responses, and automate reporting with nothing more than PowerShell and an app registration. By leveraging application permissions, you avoid license dependencies from tools like Power BI or PowerApps, and instead gain a lightweight, cost-effective, and fully scriptable solution. Whether you’re building scheduled reports, archiving responses, or integrating survey data into larger workflows, this method provides the missing automation link for Microsoft Forms.

Stay Tuned for Part 2: In the upcoming post, we’ll delve into creating, deleting, and updating Microsoft Forms programmatically using PowerShell. Don’t miss out on these advanced automation techniques!

Leave a Reply

Your email address will not be published. Required fields are marked *