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

Written by

in

, ,

Unlock full CRUD access to Microsoft Forms through PowerShell and Azure App Registrations

In Part 1 we reverse-engineered the read side of Microsoft Forms’ hidden API — enough to pull forms and responses straight into PowerShell without ever opening a browser.

No premium connectors, no extra licensing — just an App Registration treating Forms as if it were an official Microsoft Graph API.

In this part, we’ll unlock the mutation side of the same API:

  • Creating new forms
  • Adding questions and sections to them
  • Updating titles, descriptions and settings
  • Deleting forms when you’re done

One change since Part 1: Microsoft has been moving the Forms API off forms.office.com and onto forms.cloud.microsoft as part of a broader push to consolidate Microsoft 365 services onto dedicated, more tightly-scoped domains for better security.

Same disclaimer as Part 1 applies: none of this is documented by Microsoft. It’s reconstructed from watching what the Forms web app actually sends over the wire, so treat it as unofficial and test carefully before relying on it for anything important.

Updating our App Registration in Azure

In Part 1, we set up an Azure App Registration with Forms.Read.All and Forms.Read permissions — enough to read data from Microsoft Forms.

To create, update, or delete forms, we need to grant our app write access instead. That means swapping the read-only scopes for:

  • Forms.ReadWrite.All — application permission
  • Forms.ReadWrite — delegated permission
  1. Go to Azure Portal → App Registrations
  2. Select the app you created in Part 1 (or create a new one if you’d rather keep read and write access separate)
  3. Under API Permissions → Add a permission → APIs my organization uses, search for Microsoft Forms
  4. Select Application permissions → Forms.ReadWrite.All (for group-owned forms, use Delegated permissions → Forms.ReadWrite instead)
  5. Grant admin consent

That upgrades the app from read-only to full CRUD access on Forms — letting you create, edit and delete forms just like the Forms web app does internally.

Updating the Token Manager Class to support Creating Forms and Certificate Authentication

The TokenManager class from Part 1 supported client-secret and ROPC token flows. Support has been added for certificate-based authentication too.

  • ROPC (Resource Owner Password Credentials) — authenticating as a specific user with delegated permissions, useful when you want forms created “as” a real person rather than an app
  • Certificate-based authentication — a more secure alternative to client secrets, using a JWT client assertion signed with a certificate’s private key instead of a shared secret
  • A resource parameter — some of the older v1 token endpoints (still used by parts of the Forms create flow) expect resource rather than the v2 scope parameter

Two implementation details are worth calling out, since they explain a couple of choices in the class below:

  • The six auth combinations are built as named static factory methods[TokenManager]::FromClientSecret(...), ::FromCertificate(...), ::FromRopcClientSecret(...) and so on — rather than overloaded constructors. PowerShell resolves constructor overloads by parameter type, and two of the six combinations (ROPC with a secret, and client-credentials with a certificate) both need exactly six string parameters in a row — an identical signature PowerShell won’t allow as two separate constructors. Named factories sidestep that collision entirely and make each call site self-documenting.
  • The client-assertion JWT header carries a PS256 algorithm and an x5t#S256 claim — the base64url-encoded SHA-256 thumbprint of the signing certificate — matching Microsoft’s current documented format for certificate credentials, rather than the older (still widely-used, but no longer the documented) RS256/SHA-1 x5t pairing.
  • The JWT’s aud claim matches whichever token endpoint this specific request is actually going to — the v1 URL for the WithResource factories, the v2.0 URL otherwise. Entra validates the assertion’s audience against the realm of the endpoint that receives the request, so a v1 request needs a v1 aud and a v2 request needs a v2 aud. Mismatching the two is exactly what produces AADSTS700023: Client assertion audience claim does not match Realm issuer — a genuinely easy mistake to make, since plenty of certificate-auth sample code only ever exercises the v2 path and hardcodes aud accordingly.

RefreshToken() sends scope and resource together whenever both are set — the v1 endpoint just ignores the extra scope parameter, so there’s no need to withhold it.

Here’s the class in full. It supports six combinations: client-credentials or ROPC, secret or certificate, with or without a v1 resource:

PowerShell
class TokenManager {
    [string]$TenantId
    [string]$ClientId
    [string]$ClientSecret
    [string]$CertificateThumbprint
    [string]$CertificateStoreLocation
    [string]$CertificateStoreName
    [string]$Username
    [string]$Pass
    [string]$Scope
    [string]$Resource
    [string]$AccessToken
    [datetime]$Expiry
    [string]$TokenEndpoint
    [bool]$RopcFlow = $false
    [bool]$UseCertificate = $false
    [bool]$HasResource = $false

    static [TokenManager] FromClientSecret([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
        $tm = [TokenManager]::new()
        $tm.TenantId = $TenantId
        $tm.ClientId = $ClientId
        $tm.ClientSecret = $ClientSecret
        $tm.Scope = $Scope
        $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        $tm.RopcFlow = $false
        $tm.UseCertificate = $false
        $tm.HasResource = $false
        return $tm
    }

    static [TokenManager] FromCertificate([string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName, [string]$Scope) {
        $tm = [TokenManager]::new()
        $tm.TenantId = $TenantId
        $tm.ClientId = $ClientId
        $tm.CertificateThumbprint = $CertificateThumbprint
        $tm.CertificateStoreLocation = $CertificateStoreLocation
        $tm.CertificateStoreName = $CertificateStoreName
        $tm.Scope = $Scope
        $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        $tm.RopcFlow = $false
        $tm.UseCertificate = $true
        $tm.HasResource = $false
        return $tm
    }

    static [TokenManager] FromClientSecretWithResource([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope, [string]$Resource) {
        $tm = [TokenManager]::new()
        $tm.TenantId = $TenantId
        $tm.ClientId = $ClientId
        $tm.ClientSecret = $ClientSecret
        $tm.Scope = $Scope
        $tm.Resource = $Resource
        $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/token"
        $tm.RopcFlow = $false
        $tm.UseCertificate = $false
        $tm.HasResource = $true
        return $tm
    }

    static [TokenManager] FromCertificateWithResource([string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName, [string]$Scope, [string]$Resource) {
        $tm = [TokenManager]::new()
        $tm.TenantId = $TenantId
        $tm.ClientId = $ClientId
        $tm.CertificateThumbprint = $CertificateThumbprint
        $tm.CertificateStoreLocation = $CertificateStoreLocation
        $tm.CertificateStoreName = $CertificateStoreName
        $tm.Scope = $Scope
        $tm.Resource = $Resource
        $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/token"
        $tm.RopcFlow = $false
        $tm.UseCertificate = $true
        $tm.HasResource = $true
        return $tm
    }

    static [TokenManager] FromRopcClientSecret([string]$Username, [string]$Pass, [string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
        $tm = [TokenManager]::new()
        $tm.Username = $Username
        $tm.Pass = $Pass
        $tm.TenantId = $TenantId
        $tm.ClientId = $ClientId
        $tm.ClientSecret = $ClientSecret
        $tm.Scope = $Scope
        $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        $tm.RopcFlow = $true
        $tm.UseCertificate = $false
        $tm.HasResource = $false
        return $tm
    }

    static [TokenManager] FromRopcCertificate([string]$Username, [string]$Pass, [string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName, [string]$Scope) {
        $tm = [TokenManager]::new()
        $tm.Username = $Username
        $tm.Pass = $Pass
        $tm.TenantId = $TenantId
        $tm.ClientId = $ClientId
        $tm.CertificateThumbprint = $CertificateThumbprint
        $tm.CertificateStoreLocation = $CertificateStoreLocation
        $tm.CertificateStoreName = $CertificateStoreName
        $tm.Scope = $Scope
        $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        $tm.RopcFlow = $true
        $tm.UseCertificate = $true
        $tm.HasResource = $false
        return $tm
    }

    [string]NewJwt([string]$ClientId, [string]$TokenEndpoint, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName) {
        $cert = Get-Item -Path "Cert:$CertificateStoreLocation$CertificateStoreName$CertificateThumbprint" -ErrorAction Stop
        if (-not $cert) {
            throw "Certificate with thumbprint '$CertificateThumbprint' not found in store $CertificateStoreLocation$CertificateStoreName."
        }

        $now = [Math]::Floor([decimal](Get-Date (Get-Date).ToUniversalTime() -UFormat "%s"))

        $header = @{
            alg        = "PS256"
            typ        = "JWT"
            "x5t#S256" = [Convert]::ToBase64String($cert.GetCertHash([System.Security.Cryptography.HashAlgorithmName]::SHA256)) -replace '\+', '-' -replace '/', '_' -replace '='
        } | ConvertTo-Json -Compress

        $payload = @{
            aud = $TokenEndpoint
            exp = $now + 600
            iss = $ClientId
            jti = [guid]::NewGuid().ToString()
            nbf = $now
            sub = $ClientId
        } | ConvertTo-Json -Compress

        $headerBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($header)) -replace '\+', '-' -replace '/', '_' -replace '='
        $payloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload)) -replace '\+', '-' -replace '/', '_' -replace '='
        $toSign = "$headerBase64.$payloadBase64"

        $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
        if (-not $rsa) { throw "Certificate does not have an accessible private key." }

        $signature = $rsa.SignData([Text.Encoding]::UTF8.GetBytes($toSign), [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pss)
        $signatureBase64 = [Convert]::ToBase64String($signature) -replace '\+', '-' -replace '/', '_' -replace '='

        return "$toSign.$signatureBase64"
    }

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

        return $this.AccessToken
    }

    [void]RefreshToken() {
        $body = @{
            client_id  = $this.ClientId
            grant_type = "client_credentials"
        }

        if ($this.Scope) {
            $body["scope"] = $this.Scope
        }

        if ($this.HasResource) {
            $body["resource"] = $this.Resource
        }

        if ($this.UseCertificate) {
            $clientAssertion = $this.NewJwt(
                $this.ClientId,
                $this.TokenEndpoint,
                $this.CertificateThumbprint,
                $this.CertificateStoreLocation,
                $this.CertificateStoreName
            )

            $body["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
            $body["client_assertion"] = $clientAssertion
        }
        else {
            $body["client_secret"] = $this.ClientSecret
        }

        if ($this.RopcFlow) {
            $body["username"] = $this.Username
            $body["password"] = $this.Pass
            $body["grant_type"] = "password"
        }

        $tokenResponse = Invoke-RestMethod -Uri $this.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)."
    }
}
ROPC flow with username/password and client secret

Authenticates as a specific user, using a client secret to prove the app’s identity:

PowerShell
$tokenManager = [TokenManager]::FromRopcClientSecret(
    "[email protected]",
    "UserPassword123!",
    "<tenant id>",
    "<client id>",
    "<client secret>",
    "https://forms.cloud.microsoft/.default"
)
ROPC flow with username/password and certificate

Same delegated flow, but authenticating the app with a certificate instead of a secret:

PowerShell
$tokenManager = [TokenManager]::FromRopcCertificate(
    "[email protected]",
    "UserPassword123!",
    "<tenant id>",
    "<client id>",
    "<certificate thumbprint>",
    "LocalMachine",
    "My",
    "https://forms.cloud.microsoft/.default"
)
Client credentials flow with client secret

The application-only flow we used in Part 1:

PowerShell
$tokenManager = [TokenManager]::FromClientSecret(
    "<tenant id>",
    "<client id>",
    "<client secret>",
    "https://forms.cloud.microsoft/.default"
)
Client credentials flow with certificate

Application-only, authenticated with a certificate:

PowerShell
$tokenManager = [TokenManager]::FromCertificate(
    "<tenant id>",
    "<client id>",
    "<certificate thumbprint>",
    "LocalMachine",
    "My",
    "https://forms.cloud.microsoft/.default"
)
Client credentials flow with client secret and resource

Some Forms create endpoints still expect a v1-style resource token rather than a v2 scope token — this factory targets the v1 endpoint. The resource value has to be Microsoft Forms’ own Application ID URIapi://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87 — not a bare domain. A plain https://forms.cloud.microsoft still gets a token issued without error, but that token’s audience doesn’t actually match what Forms expects, and the mismatch only surfaces later as a confusing failure on the API call itself rather than a clean rejection at token time:

PowerShell
$tokenManager = [TokenManager]::FromClientSecretWithResource(
    "<tenant id>",
    "<client id>",
    "<client secret>",
    "https://forms.cloud.microsoft/.default",
    "api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"
)
Client credentials flow with certificate and resource

Same v1 resource flow, authenticated with a certificate:

PowerShell
$tokenManager = [TokenManager]::FromCertificateWithResource(
    "<tenant id>",
    "<client id>",
    "<certificate thumbprint>",
    "LocalMachine",
    "My",
    "https://forms.cloud.microsoft/.default",
    "api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"
)

Creating a New Form with PowerShell

With write scopes in place and a token manager that can handle whichever auth flow we need, we can create a form. Here’s New-MSForm:

PowerShell
function New-MSForm {
    [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 = $false)]
        [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
        [string]$Title = "Untitled form",

        [Parameter(Mandatory = $true)]
        [ValidateSet("Anyone", "OrgAnyone", "OrgSpecific")]
        [string]$ResponseMode,

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

        [Parameter(Mandatory = $false)]
        [switch]$RequiresUniqueResponse,

        [Parameter(Mandatory = $false)]
        [switch]$NotRecordIdentity,

        [Parameter(Mandatory = $false)]
        [bool]$ProgressBarEnabled = $false,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$SpecificResponders
    )

    begin {
        $IsAnonymous = $false

        switch ($ResponseMode) {
            "Anyone" {
                $IsAnonymous = $true
                $RequiresUniqueResponse = $false
                $NotRecordIdentity = $false

                if ($PSBoundParameters.ContainsKey('RequiresUniqueResponse')) {
                    throw "RequiresUniqueResponse cannot be set for Anyone mode"
                }

                if ($PSBoundParameters.ContainsKey('NotRecordIdentity')) {
                    throw "NotRecordIdentity cannot be set for Anyone mode"
                }
            }
        }

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

        $endpoint = "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$OwnerId/forms"

        $settings = @{
            RequiresUniqueResponse = [bool]$RequiresUniqueResponse
            IsAnonymous            = [bool]$IsAnonymous
            NotRecordIdentity      = [bool]$NotRecordIdentity
            IsQuizMode             = $false.ToString().ToLowerInvariant()
            PermissionForResponder = 1
        }

        $body = @{
            settings           = ($settings | ConvertTo-Json -Compress)
            ownerId            = $OwnerId
            ownerTenantId      = $TenantId
            progressBarEnabled = $ProgressBarEnabled.ToString().ToLowerInvariant()
            title              = $Title
        }
    }

    process {
        try {
            Write-Verbose "Creating new Microsoft Form titled '$Title'..."

            $formCreateResponse = Invoke-HttpRequestWithToken -Method POST -Uri $endpoint -TokenManager $TokenManager -Body $body
        }
        catch {
            throw "Failed to create form: $($_.Exception.Message)"
        }

        if ($ResponseMode -eq "OrgSpecific" -and $PSBoundParameters.ContainsKey('SpecificResponders')) {
            try {
                Write-Verbose "Setting specific responder permissions..."

                Set-MSFormResponderPermissions `
                    -TenantId $TenantId `
                    -OwnerId $OwnerId `
                    -OwnerType $OwnerType `
                    -FormId $formCreateResponse.id `
                    -Users $SpecificResponders `
                    -TokenManager $TokenManager
            }
            catch {
                Write-Host "Failed to add users to form: $($_.Exception.Message)" -ForegroundColor Red
            }
        }
    }

    end {
        return $formCreateResponse
    }
}
Understanding the parameters

ResponseMode is the one that matters most — it maps onto the same “who can fill this in” choices you’d see in the Forms UI:

  • Anyone — a public, anonymous link. No sign-in required, no identity recorded.
  • OrgAnyone — anyone signed into your organisation’s tenant can respond.
  • OrgSpecific — only named individuals can respond, passed via SpecificResponders.

RequiresUniqueResponse restricts each respondent to a single submission, and NotRecordIdentity stops the form from recording who submitted a response even though they had to sign in to access it. Both only make sense for OrgAnyone or OrgSpecific forms — the function throws if you try to set either alongside Anyone, since an anonymous form has no identity to de-duplicate or record in the first place.

Example Usages

All three examples below assume a v1-resource token, since the create endpoint is one of the ones that still wants a resource-flavoured token:

PowerShell
$tokenManager = [TokenManager]::FromClientSecretWithResource(
    "<tenant id>",
    "<client id>",
    "<client secret>",
    "https://forms.cloud.microsoft/.default",
    "api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"
)
Example 1: Public form

Anyone with the link can respond — no sign-in, no identity recorded:

PowerShell
New-MSForm `
    -TenantId "<tenant id>" `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -Title "Customer Feedback" `
    -ResponseMode "Anyone" `
    -TokenManager $tokenManager
Example 2: Anyone in the organisation

Anyone signed into the tenant can respond, and we’re limiting each person to one response:

PowerShell
New-MSForm `
    -TenantId "<tenant id>" `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -Title "Staff Survey" `
    -ResponseMode "OrgAnyone" `
    -RequiresUniqueResponse `
    -TokenManager $tokenManager
Example 3: Specific people only

Only the named respondents can access the form:

PowerShell
New-MSForm `
    -TenantId "<tenant id>" `
    -OwnerId "<entra object id>" `
    -OwnerType "User" `
    -Title "Leadership Team Review" `
    -ResponseMode "OrgSpecific" `
    -SpecificResponders @("[email protected]", "[email protected]") `
    -TokenManager $tokenManager

Updating Form Details

From here on, every function needs the same four pieces of context — TenantId, OwnerId, OwnerType and FormId — to build the form’s endpoint. Rather than repeating that construction in every function, I’ve pulled it into one small helper that the rest of the library calls internally:

PowerShell
function Get-MSFormEndpoint {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId
    )

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

    return "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$OwnerId/forms('$FormId')"
}

A form created with New-MSForm has a title but no description. Both are set with a PATCH straight to the form’s own resource URL — and interestingly, they’re captured as two separate requests rather than one combined update, so the functions below keep that split rather than merging them. I’m keeping the generic Invoke-HttpRequestWithToken helper from earlier in the post too — every function below is really just a thin, validated wrapper around it:

PowerShell
function Update-MSFormTitle {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        title           = $Title
        formsProRTTitle = $Title
    }
}

function Update-MSFormDescription {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Description,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        description           = $Description
        formsProRTDescription = $Description
    }
}

The duplicated title/formsProRTTitle and description/formsProRTDescription pairs aren’t a typo — the Forms web app writes both every time. Why isn’t clear from the captures alone; the “RT” naming hints at something rich-text-related, but that’s a guess rather than a confirmed reason, so both are set here to keep the two fields in sync regardless.

PowerShell
Update-MSFormTitle -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Title "My Updated Form Title" -TokenManager $tokenManager
Update-MSFormDescription -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Description "A short description of what this form is for." -TokenManager $tokenManager

Adding Questions to Your Form

Every question type follows the same two-step pattern under the hood: POST to the form’s questions collection to create a question, then PATCH the specific question to reconfigure it later if needed. The functions below wrap both steps — an Add-MSForm*Question function that creates a fully-configured question in one call, and an Update-MSForm*Question function for editing it afterwards.

Three quirks the functions handle for you, worth knowing about anyway:

  • The question’s id is chosen by the client, not returned by the server — the Forms web app generates a token like r61e46485c47f41c0b62760fb1e2bdcc5 (an “r” prefix followed by a random hex string) and sends it in the create request. Each Add- function below generates one with "r" + [guid]::NewGuid().ToString("N").
  • The order field jumps in increments of roughly 1,000,000 per question (1000500, 2000500, 3000500…) rather than counting up by one. That’s a sparse ordering scheme — it leaves enormous gaps so questions can be reordered or inserted later without renumbering everything else. Each function below defaults Order to its own slot in that scheme, but takes it as a parameter so you can override it.
  • The body that configures a question is doubly-encoded: the outer request body is a normal object, but the questionInfo field inside it is itself a JSON string, not a nested object. Every function builds this with ConvertTo-Json -Compress on an inner hashtable.
Choice questions
PowerShell
function Add-MSFormChoiceQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [Parameter(Mandatory)] [string[]]$Choices,
        [switch]$AllowMultipleAnswers,
        [switch]$AllowOtherAnswer,
        [switch]$ShuffleOptions,
        [switch]$Required,
        [ValidateSet("None", "Exactly", "AtMost")] [string]$RestrictionType = "None",
        [int]$RestrictionValue,
        [int]$Order = 1000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    $questionInfo = @{
        Choices                = @($Choices | ForEach-Object { @{ Description = $_; IsGenerated = $true } })
        ChoiceType              = if ($AllowMultipleAnswers) { 2 } else { 1 }
        AllowOtherAnswer        = [bool]$AllowOtherAnswer
        ShuffleOptions          = [bool]$ShuffleOptions
        OptionDisplayStyle      = "ListAll"
        ChoiceRestrictionType   = $RestrictionType
    }

    if ($RestrictionType -ne "None") {
        $questionInfo["ChoiceRestrictionValue"] = $RestrictionValue
    }

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.Choice"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        required     = [bool]$Required
        questionInfo = ($questionInfo | ConvertTo-Json -Compress)
    }
}

function Update-MSFormChoiceQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [Parameter(Mandatory)] [string[]]$Choices,
        [switch]$AllowMultipleAnswers,
        [switch]$AllowOtherAnswer,
        [switch]$ShuffleOptions,
        [switch]$Required,
        [ValidateSet("None", "Exactly", "AtMost")] [string]$RestrictionType = "None",
        [int]$RestrictionValue,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"

    $questionInfo = @{
        Choices                = @($Choices | ForEach-Object { @{ Description = $_; IsGenerated = $true } })
        ChoiceType              = if ($AllowMultipleAnswers) { 2 } else { 1 }
        AllowOtherAnswer        = [bool]$AllowOtherAnswer
        ShuffleOptions          = [bool]$ShuffleOptions
        OptionDisplayStyle      = "ListAll"
        ChoiceRestrictionType   = $RestrictionType
    }

    if ($RestrictionType -ne "None") {
        $questionInfo["ChoiceRestrictionValue"] = $RestrictionValue
    }

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
        required     = [bool]$Required
        questionInfo = ($questionInfo | ConvertTo-Json -Compress)
    }
}

ChoiceType is 1 for single-select and 2 for multiple-select, which is why the functions derive it from the -AllowMultipleAnswers switch rather than taking it directly. RestrictionType can be None, Exactly, or AtMost, paired with RestrictionValue to cap how many options can be picked. To reorder options, just call Update-MSFormChoiceQuestion again with -Choices in the order you want — unlike ranking questions (below), there’s no separate per-choice endpoint here.

PowerShell
$choiceQuestion = Add-MSFormChoiceQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "Which product did you purchase?" -Choices @("Option 1", "Option 2") -TokenManager $tokenManager

Update-MSFormChoiceQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $choiceQuestion.id `
    -Choices @("Option 1", "Option 2", "Option 3") -AllowMultipleAnswers -AllowOtherAnswer -ShuffleOptions -Required `
    -RestrictionType "Exactly" -RestrictionValue 2 -TokenManager $tokenManager
Text Field questions

Text fields can also carry a validation rule. The rule codes aren’t documented anywhere, so here’s the reverse-engineered map Update-MSFormTextFieldQuestion is built around:

  • 0IsNumber — must be a number
  • 1Greater — greater than MinBoundary
  • 2GreaterOrEqual — greater than or equal to MinBoundary
  • 3Less — less than MaxBoundary
  • 4LessOrEqual — less than or equal to MaxBoundary
  • 5Equal — equal to MinBoundary (reuses the min-boundary field for the single comparison value)
  • 6NotEqual — not equal to MinBoundary
  • 7Between — between MinBoundary and MaxBoundary
  • 8NotBetween — outside MinBoundary and MaxBoundary
  • 9 — maximum text length, via MaxBoundary
  • 10 — minimum text length, via MinBoundary
  • 11 — must be a valid email address
  • 12Contains — text must contain TextInput
  • 13DoesntContain — text must not contain TextInput
  • 14 — must be a valid URL
  • 15WholeNumber — must be a whole number
PowerShell
function Add-MSFormTextFieldQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [switch]$Multiline,
        [switch]$Required,
        [int]$Order = 2000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.TextField"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        required     = [bool]$Required
        questionInfo = (@{ Multiline = [bool]$Multiline } | ConvertTo-Json -Compress)
    }
}

function Update-MSFormTextFieldQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [switch]$Multiline,
        [ValidateRange(0, 15)] [int]$ValidationRule = -1,
        [double]$MinBoundary,
        [double]$MaxBoundary,
        [string]$TextInput,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"

    $questionInfo = @{
        Multiline             = [bool]$Multiline
        GradingBasis          = 0
        IsAnswerPlainText     = $true
        IsMathInputDefault    = $false
        InputSwitcherDisabled = $false
    }

    if ($ValidationRule -ge 0) {
        $questionInfo["NumberValidation"] = @{
            NumberValidationRule = $ValidationRule
            NumberMinBoundary    = $MinBoundary
            NumberMaxBoundary    = $MaxBoundary
            TextInput            = $TextInput
        }
    }

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
        questionInfo = ($questionInfo | ConvertTo-Json -Compress)
    }
}

Creating a short-answer question, then switching it to long-answer and restricting it to a whole number between 1 and 100:

PowerShell
$textQuestion = Add-MSFormTextFieldQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "What's your favourite feature?" -TokenManager $tokenManager

Update-MSFormTextFieldQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $textQuestion.id `
    -Multiline -ValidationRule 7 -MinBoundary 1 -MaxBoundary 100 -TokenManager $tokenManager
Rating questions
PowerShell
function Add-MSFormRatingQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [ValidateRange(2, 10)] [int]$Length = 5,
        [ValidateSet("Star", "Number", "Heart", "Ribbon", "ThumbLike", "SmileFace", "Flag", "Lightbulb", "Trophy", "CheckMark")]
        [string]$RatingShape = "Star",
        [int]$Order = 3000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.Rating"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        questionInfo = (@{ Length = $Length; RatingShape = $RatingShape } | ConvertTo-Json -Compress)
    }
}

function Update-MSFormRatingQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [ValidateRange(2, 10)] [int]$Length = 5,
        [ValidateSet("Star", "Number", "Heart", "Ribbon", "ThumbLike", "SmileFace", "Flag", "Lightbulb", "Trophy", "CheckMark")]
        [string]$RatingShape = "Star",
        [switch]$ShowRatingLabel,
        [string]$LeftDescription,
        [string]$RightDescription,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
        questionInfo = (@{
            Length            = $Length
            RatingShape       = $RatingShape
            ShowRatingLabel   = [bool]$ShowRatingLabel
            LeftDescription   = $LeftDescription
            RightDescription  = $RightDescription
        } | ConvertTo-Json -Compress)
    }
}
PowerShell
$ratingQuestion = Add-MSFormRatingQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "How would you rate our service?" -TokenManager $tokenManager

Update-MSFormRatingQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $ratingQuestion.id `
    -Length 10 -RatingShape "Number" -ShowRatingLabel -LeftDescription "Not likely" -RightDescription "Very likely" -TokenManager $tokenManager
Date questions
PowerShell
function Add-MSFormDateQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [string]$Subtitle,
        [switch]$Required,
        [int]$Order = 4000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.DateTime"
        title        = $Title
        subtitle     = $Subtitle
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        required     = [bool]$Required
        questionInfo = (@{} | ConvertTo-Json -Compress)
    }
}

function Update-MSFormDateQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [string]$Subtitle,
        [switch]$Required,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
        subtitle = $Subtitle
        required = [bool]$Required
    }
}
PowerShell
Add-MSFormDateQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "When did you make your purchase?" -Subtitle "Approximate date is fine" -Required -TokenManager $tokenManager
Ranking questions

Ranking questions behave differently from Choice questions — choices live under their own sub-resource, and reordering them means patching individual choices rather than resending the whole list, so they get their own dedicated functions:

PowerShell
function Add-MSFormRankingQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [switch]$AllowMultipleValues,
        [switch]$Required,
        [int]$Order = 5000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.Ranking"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        required     = [bool]$Required
        questionInfo = (@{ AllowMultipleValues = [bool]$AllowMultipleValues } | ConvertTo-Json -Compress)
    }
}

function Add-MSFormRankingChoice {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [Parameter(Mandatory)] [string]$Description,
        [Parameter(Mandatory)] [int]$Order,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $choicesEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')/choices"

    return Invoke-HttpRequestWithToken -Method POST -Uri $choicesEndpoint -TokenManager $TokenManager -Body @{
        Description = $Description
        order       = $Order
    }
}

function Set-MSFormRankingChoiceOrder {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [Parameter(Mandatory)] [int]$ChoiceIndex,
        [Parameter(Mandatory)] [int]$NewOrder,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $choiceEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')/choices($ChoiceIndex)"

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $choiceEndpoint -TokenManager $TokenManager -Body @{ order = $NewOrder }
}

function Remove-MSFormRankingChoice {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [Parameter(Mandatory)] [int]$ChoiceIndex,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $choiceEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')/choices($ChoiceIndex)"

    if ($PSCmdlet.ShouldProcess($choiceEndpoint, "Delete ranking choice")) {
        return Invoke-HttpRequestWithToken -Method DELETE -Uri $choiceEndpoint -TokenManager $TokenManager
    }
}
PowerShell
$rankingQuestion = Add-MSFormRankingQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "Rank these features by importance" -Required -TokenManager $tokenManager

Add-MSFormRankingChoice -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -Description "Speed" -Order 1 -TokenManager $tokenManager
Add-MSFormRankingChoice -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -Description "Reliability" -Order 2 -TokenManager $tokenManager

Set-MSFormRankingChoiceOrder -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -ChoiceIndex 1 -NewOrder 2 -TokenManager $tokenManager
Set-MSFormRankingChoiceOrder -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -ChoiceIndex 2 -NewOrder 1 -TokenManager $tokenManager
Likert (matrix) questions

Likert questions are the odd one out structurally — a “group” question holds the response scale (the columns), and each row statement is actually its own separate question object linked back to the group via groupId. That structure carries straight through into the functions:

PowerShell
function Add-MSFormLikertQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [int]$Order = 6000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.MatrixChoiceGroup"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        questionInfo = (@{} | ConvertTo-Json -Compress)
    }
}

function Add-MSFormLikertChoice {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$GroupId,
        [Parameter(Mandatory)] [string]$Description,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $choicesEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$GroupId')/choices"

    return Invoke-HttpRequestWithToken -Method POST -Uri $choicesEndpoint -TokenManager $TokenManager -Body @{ Description = $Description }
}

function Add-MSFormLikertStatement {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$GroupId,
        [Parameter(Mandatory)] [string]$Title,
        [int]$Order = 7000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.MatrixChoice"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        groupId      = $GroupId
        questionInfo = (@{} | ConvertTo-Json -Compress)
    }
}

function Remove-MSFormLikertStatement {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$StatementId,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$StatementId')"

    if ($PSCmdlet.ShouldProcess($endpoint, "Delete Likert statement")) {
        return Invoke-HttpRequestWithToken -Method DELETE -Uri $endpoint -TokenManager $TokenManager
    }
}

function Remove-MSFormLikertChoice {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$GroupId,
        [Parameter(Mandatory)] [int]$ChoiceIndex,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$GroupId')/choices($ChoiceIndex)"

    if ($PSCmdlet.ShouldProcess($endpoint, "Delete Likert choice column")) {
        return Invoke-HttpRequestWithToken -Method DELETE -Uri $endpoint -TokenManager $TokenManager
    }
}
PowerShell
$likertGroup = Add-MSFormLikertQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "How satisfied are you with the following?" -TokenManager $tokenManager

Add-MSFormLikertChoice -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -GroupId $likertGroup.id -Description "Very satisfied" -TokenManager $tokenManager

Add-MSFormLikertStatement -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -GroupId $likertGroup.id -Title "Response time" -TokenManager $tokenManager
File Upload questions

File Upload follows the same generic-create-then-configure pattern as Date, NPS and the Likert group — questionInfo is empty on creation, and file count, size and type restrictions are set afterwards through Update-MSFormFileUploadQuestion.

PowerShell
function Add-MSFormFileUploadQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Title,
        [switch]$Required,
        [string]$Subtitle,
        [int]$Order = 8000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    $body = @{
        type         = "Question.FileUpload"
        title        = $Title
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        required     = [bool]$Required
        questionInfo = (@{} | ConvertTo-Json -Compress)
    }
    if ($PSBoundParameters.ContainsKey('Subtitle')) {
        $body['subtitle'] = $Subtitle
    }

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body $body
}

FileTypes has to be a nested object listing all seven file types every time, not just the ones you’re restricting to, and three fields that have nothing to do with file uploads — ShuffleOptions, ShowRatingLabel, IsMathQuiz — get sent as constant boilerplate alongside it. Leave any of that out and the request fails with the same vague, unhelpful error rather than a clear validation message.

PowerShell
function Update-MSFormFileUploadQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [int]$MaxFileCount = 1,
        [int]$MaxFileSizeMB = 10,
        [switch]$Required,
        [string]$Subtitle,
        [ValidateSet("Word", "Excel", "PowerPoint", "PDF", "Image", "Video", "Audio")]
        [string[]]$AllowedFileTypes,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"

    $allFileTypes = @("Word", "Excel", "PowerPoint", "PDF", "Image", "Video", "Audio")
    $fileTypes = @{}
    foreach ($fileType in $allFileTypes) {
        $fileTypes[$fileType] = if ($AllowedFileTypes) { $AllowedFileTypes -contains $fileType } else { $true }
    }

    $questionInfo = @{
        HasSpecificFileType = [bool]$AllowedFileTypes
        FileTypes           = $fileTypes
        MaxFileCount        = $MaxFileCount
        MaxFileSize         = $MaxFileSizeMB
        ShuffleOptions      = $false
        ShowRatingLabel     = $false
        IsMathQuiz          = $false
    }

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
        required     = [bool]$Required
        subtitle     = $Subtitle
        questionInfo = ($questionInfo | ConvertTo-Json -Compress)
    }
}

MaxFileSizeMB maps straight onto the API’s MaxFileSize, which is in MB — so 1GB is -MaxFileSizeMB 1000:

PowerShell
$uploadQuestion = Add-MSFormFileUploadQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Title "Upload your receipt" -TokenManager $tokenManager

Update-MSFormFileUploadQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $uploadQuestion.id `
    -MaxFileCount 3 -MaxFileSizeMB 1000 -Required -Subtitle "PDF or image files only, max 3 files" -AllowedFileTypes @("PDF", "Image") -TokenManager $tokenManager
NPS (Net Promoter Score) questions

NPS is the only question type that ships with a non-generic default title baked into the Forms UI, so Add-MSFormNPSQuestion defaults to it too:

PowerShell
function Add-MSFormNPSQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [string]$Title = "How likely are you to recommend us to a friend or colleague?",
        [string]$Subtitle,
        [int]$Order = 9000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
        type         = "Question.NPS"
        title        = $Title
        subtitle     = $Subtitle
        id           = "r" + [guid]::NewGuid().ToString("N")
        order        = $Order
        isQuiz       = $false
        questionInfo = (@{} | ConvertTo-Json -Compress)
    }
}

function Update-MSFormNPSQuestion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$QuestionId,
        [string]$Subtitle,
        [string]$LeftDescription,
        [string]$RightDescription,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
        subtitle     = $Subtitle
        questionInfo = (@{ LeftDescription = $LeftDescription; RightDescription = $RightDescription } | ConvertTo-Json -Compress)
    }
}
PowerShell
$npsQuestion = Add-MSFormNPSQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager

Update-MSFormNPSQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $npsQuestion.id `
    -Subtitle "0 = not at all likely, 10 = extremely likely" -LeftDescription "Not at all likely" -RightDescription "Extremely likely" -TokenManager $tokenManager

Adding Sections

A section turns out to follow the same pattern as every question type in this post: a generic POST to a sub-collection — descriptiveQuestions rather than questions — followed by a PATCH to fill in the details. Add-MSFormSection creates a bare placeholder section (type/title/id/order/isQuiz only, no questionInfo), and Update-MSFormSection below is what actually sets its real title and subtitle:

PowerShell
function Add-MSFormSection {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [int]$Order = 10000500,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $sectionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/descriptiveQuestions"

    return Invoke-HttpRequestWithToken -Method POST -Uri $sectionsEndpoint -TokenManager $TokenManager -Body @{
        type   = "Question.ColumnGroup"
        title  = "Section"
        id     = "r" + [guid]::NewGuid().ToString("N")
        order  = $Order
        isQuiz = $false
    }
}

function Update-MSFormSection {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$SectionId,
        [Parameter(Mandatory)] [string]$Title,
        [string]$Subtitle,
        [int]$Order = 1500000,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/descriptiveQuestions('$SectionId')"

    $wrappedTitle = "<span>$Title</span>"

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        order                   = $Order
        title                   = $wrappedTitle
        formsProRTQuestionTitle = $wrappedTitle
        subtitle                = $Subtitle
    }
}

Once created, the section shows up as a descriptiveQuestions entry on the form, which is what Update-MSFormSection targets to set its title/description and move it, using the same sparse order scheme as questions.

PowerShell
$section = Add-MSFormSection -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager

Update-MSFormSection -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -SectionId $section.id `
    -Title "Section 2: Follow-up questions" -Subtitle "A few extra questions if you have time" -TokenManager $tokenManager

Configuring Form Settings

Everything under the Forms “Settings” panel — who can respond, scheduling, limits, and notifications — is a PATCH to the form’s own resource URL, just with a different settings payload each time.

Who Can Respond

Set-MSFormResponseMode deliberately mirrors New-MSForm‘s ResponseMode parameter, since it’s setting the exact same three options after the fact instead of at creation time:

PowerShell
function Set-MSFormResponseMode {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [ValidateSet("Anyone", "OrgAnyone", "OrgSpecific")] [string]$ResponseMode,
        [switch]$RequiresUniqueResponse,
        [switch]$NotRecordIdentity,
        [string[]]$SpecificResponders,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    if ($ResponseMode -eq "Anyone" -and ($RequiresUniqueResponse -or $NotRecordIdentity)) {
        throw "RequiresUniqueResponse and NotRecordIdentity cannot be set for Anyone mode"
    }

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    $response = Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        settings = (@{
            RequiresUniqueResponse = [bool]$RequiresUniqueResponse
            IsAnonymous             = ($ResponseMode -eq "Anyone")
            NotRecordIdentity       = [bool]$NotRecordIdentity
        } | ConvertTo-Json -Compress)
    }

    if ($ResponseMode -eq "OrgSpecific" -and $SpecificResponders) {
        Set-MSFormResponderPermissions -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId -Users $SpecificResponders -TokenManager $TokenManager
    }

    return $response
}

function Set-MSFormResponderPermissions {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string[]]$Users,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $formEndpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    Invoke-HttpRequestWithToken -Method POST -Uri "$formEndpoint/responderPermissions" -TokenManager $TokenManager -Body @{
        permissionSet = "DataCreator"
        type          = "SpecificResponder"
        principalId   = "SpecificResponder"
    }

    foreach ($user in $Users) {
        Invoke-HttpRequestWithToken -Method POST -Uri "$formEndpoint/permissions" -TokenManager $TokenManager -Body @{
            recipients    = @(@{ email = $user })
            permissionSet = "Read"
            type          = "ShareToPeople"
        }
    }
}

Worth flagging: the captured responderPermissions call itself doesn’t take a list of people — its principalId is literally the string "SpecificResponder", which reads more like a mode-switch (turn on “specific responders only”) than a per-person grant. The actual list of named respondents appears to be handled separately through the same /permissions sharing endpoint used for collaborators below. I’ve combined both calls above since that matches how New-MSForm uses this function, but if your captures show something different for adding named respondents, treat that part as the least certain piece of this whole write-up. Bear in mind Forms won’t let you use Anyone mode if the form contains a File Upload question.

PowerShell
Set-MSFormResponseMode -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -ResponseMode "OrgSpecific" -SpecificResponders @("[email protected]", "[email protected]") -TokenManager $tokenManager
Scheduling & Access Windows

The Forms UI doesn’t let you set a schedule on a form that’s currently closed, so Set-MSFormSchedule uses parameter sets to keep “close it now” and “schedule a window” mutually exclusive:

PowerShell
function Set-MSFormSchedule {
    [CmdletBinding(DefaultParameterSetName = "Window")]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,

        [Parameter(Mandatory, ParameterSetName = "Close")] [switch]$Close,
        [Parameter(ParameterSetName = "Close")] [string]$ClosedMessage,

        [Parameter(ParameterSetName = "Window")] [datetime]$StartDate,
        [Parameter(ParameterSetName = "Window")] [datetime]$EndDate,

        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    $settings = if ($PSCmdlet.ParameterSetName -eq "Close") {
        @{ IsClosed = $true; ClosedMessage = $ClosedMessage }
    }
    else {
        @{
            IsClosed  = $false
            StartDate = if ($StartDate) { $StartDate.ToString("o") }
            EndDate   = if ($EndDate) { $EndDate.ToString("o") }
        }
    }

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        settings = ($settings | ConvertTo-Json -Compress)
    }
}
PowerShell
Set-MSFormSchedule -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -Close -ClosedMessage "This form is no longer accepting responses. Thanks for your interest!" -TokenManager $tokenManager

Set-MSFormSchedule -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -StartDate "2026-09-01T00:00:00Z" -EndDate "2026-09-30T23:59:59Z" -TokenManager $tokenManager
Timeout / Auto-Submit
PowerShell
function Set-MSFormTimeLimit {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [int]$TimeLimitSeconds,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        settings = (@{ FillOutTimeLimit = $TimeLimitSeconds } | ConvertTo-Json -Compress)
    }
}
PowerShell
Set-MSFormTimeLimit -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TimeLimitSeconds 600 -TokenManager $tokenManager
Display Options
PowerShell
function Set-MSFormDisplayOptions {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [switch]$HideQuestionNumbers,
        [bool]$ProgressBarEnabled = $true,
        [string]$ThankYouMessage,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        settings           = (@{ HideQuestionNumbers = [bool]$HideQuestionNumbers } | ConvertTo-Json -Compress)
        progressBarEnabled = $ProgressBarEnabled.ToString().ToLowerInvariant()
        thankYouMessage    = $ThankYouMessage
    }
}

Passing an empty -ThankYouMessage reverts to the default “Your response has been submitted” screen:

PowerShell
Set-MSFormDisplayOptions -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
    -HideQuestionNumbers -ProgressBarEnabled $false -ThankYouMessage "Thanks for taking the time to fill this in — we appreciate it." -TokenManager $tokenManager
Respondent Permissions

Save-and-resume versus edit-after-submit are both controlled by the same numeric PermissionForResponder field, so Set-MSFormResponderCapabilities hides the numbers behind a named set. Note edit-after-submit isn’t available once a time limit is set:

PowerShell
function Set-MSFormResponderCapabilities {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [ValidateSet("None", "SaveAndResume", "EditAfterSubmit")] [string]$Capability,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $permissionForResponder = switch ($Capability) {
        "None"             { 0 }
        "SaveAndResume"    { 1 }
        "EditAfterSubmit"  { 2 }
    }

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        settings = (@{ PermissionForResponder = $permissionForResponder } | ConvertTo-Json -Compress)
    }
}
PowerShell
Set-MSFormResponderCapabilities -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Capability "EditAfterSubmit" -TokenManager $tokenManager
Notifications & Collaborators

Notifying the form owner by email on every new response is a two-step process: first grant a collaborator access via the /permissions sharing endpoint, then that person’s permission set is updated to include EmailNotification. Add-MSFormCollaborator does both in one call when you pass -NotifyByEmail:

PowerShell
function Set-MSFormResponseReceipt {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [bool]$Enabled,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
        settings = (@{ SendResponseReceipt = $Enabled } | ConvertTo-Json -Compress)
    }
}

function Add-MSFormCollaborator {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [string]$Email,
        [switch]$NotifyByEmail,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $permissionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/permissions"

    $shareResponse = Invoke-HttpRequestWithToken -Method POST -Uri $permissionsEndpoint -TokenManager $TokenManager -Body @{
        recipients    = @(@{ email = $Email })
        permissionSet = "Read, Write, Share"
        type          = "ShareToPeople"
    }

    if ($NotifyByEmail) {
        return Invoke-HttpRequestWithToken -Method PATCH -Uri "$permissionsEndpoint('$($shareResponse.principalId)')" -TokenManager $TokenManager -Body @{
            permissionSet = "Read, Write, Share, EmailNotification"
        }
    }

    return $shareResponse
}
PowerShell
Set-MSFormResponseReceipt -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Enabled $true -TokenManager $tokenManager

Add-MSFormCollaborator -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Email "[email protected]" -NotifyByEmail -TokenManager $tokenManager
Reading Current Settings
PowerShell
function Get-MSFormSettings {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    return Invoke-HttpRequestWithToken -Method GET -Uri $endpoint -TokenManager $TokenManager
}

The easiest way to confirm any of the changes above actually landed:

PowerShell
Get-MSFormSettings -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager

Deleting a Form

Deleting follows the same rule as every other resource in this API (questions, choices, statements): a DELETE against the resource’s own URL removes it, and a form is no exception. Remove-MSForm wraps that in a plain DELETE to the form’s endpoint, guarded behind -Confirm since it’s destructive:

PowerShell
function Remove-MSForm {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    param (
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter(Mandatory)] [string]$OwnerId,
        [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
        [Parameter(Mandatory)] [string]$FormId,
        [Parameter(Mandatory)] [TokenManager]$TokenManager
    )

    $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId

    if ($PSCmdlet.ShouldProcess($endpoint, "Delete Microsoft Form")) {
        return Invoke-HttpRequestWithToken -Method DELETE -Uri $endpoint -TokenManager $TokenManager
    }
}

The -Confirm is optional rather than load-bearing here — ConfirmImpact = "High" already means PowerShell will prompt by default at the standard $ConfirmPreference level, so it’s really there as a reminder that this one is one-way.

PowerShell
Remove-MSForm -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager -Confirm

Final Thoughts

Between Part 1 and this post, that’s the full loop covered — read forms and responses, then create, configure, and delete forms, all from PowerShell and without touching the Forms web UI.

The API surface is bigger than either post lets on; things like conditional branching between questions, quiz scoring, and collaborator management have more depth than there was room to cover here.

As with everything in this series, none of it is documented or supported by Microsoft, so build in your own error handling and don’t assume today’s request shapes will still work tomorrow.

I’m currently working on packaging all of this up as a proper PowerShell module, along with the functionality that hasn’t made it into either post — including the deeper areas mentioned above. Once it’s ready, I’ll release it as Part 3 and make the module available on GitHub.

Comments

Leave a Reply

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