Monday, July 7, 2025

Azure Automation for Shared Calling Enablement

Automating Enterprise Voice Enablement for Teams Shared Calling: A Journey in Iteration

This one’s a long read—because the work was iterative, the scope deceptively simple, and the edge cases... well, they were not shy.



The goal? Automate Enterprise Voice (EV) enablement for users in Microsoft Teams Shared Calling scenarios. Many organizations are adopting Shared Calling to provide basic PSTN access to all users while reserving DIDs and calling plans for high-volume users. It’s cost-effective, scalable, and flexible. But there’s a catch: even with group-based licensing and policy assignment in Entra ID, Teams doesn’t automatically flip the Enterprise Voice bit. That still requires PowerShell or a manual toggle in the Teams Admin Center.

So I built an automation to do just that.

Why This Matters

This model—what we affectionately call a “reverse migration” (credit to Matt Edlhuber)—lets organizations enable outbound and auto-attendant-based inbound calling for everyone. Then, based on usage or cost analysis, they can selectively assign DIDs and calling plans when porting timelines align. It’s a way to decouple enablement from carrier constraints.
The Setup

Picture this: you’ve just migrated hundreds of users to Shared Calling using PowerShell. High-fives all around. But now you need to ensure they’re EV-enabled. Manually? No thanks.

Here’s the stack I used:
  • Entra ID: Security group membership drives license and policy assignment.
  • Microsoft Graph API: Subscribes to group membership changes.
  • Azure Logic App: The orchestration layer.
  • Webhook Trigger: Fires on group updates.
  • Azure Automation Account: Hosts the PowerShell runbook.
  • Runbook: Validates license and applies EV enablement.

The Obvious Path

Iteration 1: Sure, I could’ve scheduled a daily PowerShell job or used Power Automate to trigger the runbook. Shoutout to Laure Vanderhauert for the excellent documentation that got me started.
But I wanted near-real-time enablement. Why wait a day when we can act in minutes?

Challenge #1: Detecting Deltas
The first hurdle: how do we detect only the new users added to the group? Most orgs already automate license and policy assignment, but EV enablement is often manual. I needed a way to isolate just the new additions.

I’d previously worked with Graph API subscriptions and Azure Event Grid in Call Record Insights, so I figured I could apply a similar pattern here.

Spoiler: Event Grid doesn’t give you the delta. It tells you a group changed, but not how. No user info in the payload = no go.

Enter Copilot(s)

This is where GitHub Copilot and M365 Copilot saved me hours. I’ll write more soon about using Claude Sonnet 4 in Agentic vs Ask mode in VS Code. TL;DR: Agentic mode is powerful, but Ask mode gave me the iterative control I needed to learn as I built.
Iteration 2: Build the Runbook First

I started with the end in mind: a runbook that accepts a user ID and group ID, validates licensing, and enables EV. I tested it locally in VS Code, then manually in the Azure Portal. It worked.

Then life happened. I paused.
Iteration 3: Logic App + Graph Subscription

Back at it, I wired up the Logic App to the Graph subscription. It worked—until it didn’t.

Challenge #2: Add ≠ Remove
Turns out, Graph fires on any group membership change. Add or remove. My Logic App didn’t discriminate, so it happily re-enabled users who had just been removed. Oops.

Fix: I added logic to filter for additions only. Most orgs remove licenses and policies when users leave the group, so I focused on the “add” path.

Challenge #3: Bulk Adds
What happens when multiple users are added at once? Is the payload an array? Do we get one notification per user? I had to build logic to handle both cases.

Challenge #4: The Subscription That Multiplies
When testing your Graph subscription and Logic App flow, it’s surprisingly easy to accidentally create multiple subscriptions. And when you do? Each one will happily fire off its own webhook, triggering your Logic App and runbook multiple times.


I’ll go deeper into subscription setup in the next section, but this one deserves a spotlight.
Here’s the key:
  • Make sure you only have one active subscription.
  • Only monitor the resource: /groups/{group-id}/members
That last part—members—is critical. If you subscribe to just /groups/{group-id}, you’ll get notified on any group change (like metadata updates), not just membership changes. That’s a fast track to unintended runbook executions and potential chaos.
So, before you hit “Deploy,” double-check:You’re not stacking subscriptions.
You’re watching the right resource.
You’re not about to create a webhook-triggered infinite loop.

Trust me, your future self will thank you.

The Build: Where the Magic Happens

Let’s talk about the build. The real magic lies in the Graph API subscription and the Azure Logic App with a webhook trigger. But first, let’s set the scene.


Graph Subscription: Your Digital Bouncer

Imagine you’re the bouncer at Club Entra. You don’t want to stand at the door all night checking who’s coming and going from the VIP group (say, “Teams Voice Users”). So you hire Microsoft Graph to do it for you.

A Graph API subscription is your way of saying:

“Hey Graph, tap me on the shoulder whenever someone joins or leaves this group.”

Here’s what that looks like in practice:

POST https://graph.microsoft.com/v1.0/subscriptions
{
  "changeType": "updated",
  "notificationUrl": "https://yourlogicapp.azurewebsites.net/api/notify",
  "resource": "/groups/{group-id}/members",
  "expirationDateTime": "2025-07-07T11:00:00Z",
  "clientState": "secretSauce123"
}

What’s Going On Here?

  • changeType: "updated" — You care about membership changes.
  • resource: The Entra ID group you’re watching.
  • notificationUrl: Where Graph sends the “Yo, something changed!” message.
  • clientState: A secret handshake to verify the message is legit.
Graph will first validate your notificationUrl to make sure it’s not a prank. Once that handshake is done, you’re officially subscribed.

When someone joins or leaves the group, Graph sends a POST to your notificationUrl with a payload like this:
{
  "value": [
    {
      "subscriptionId": "...",
      "changeType": "updated",
      "resource": "groups/{group-id}/members",
      "resourceData": {
        "id": "user-id"
      }
    }
  ]
}
It’s like getting a text that says, “Someone just walked into the VIP room,” and then checking the security cam to see who it was.

Azure Logic App: Your Always-On Concierge

Your Logic App is the concierge that handles these notifications:
  • Trigger: HTTP request from Graph hits your Logic App.
  • Parse: Extract the user-id from the payload.
  • Lookup: Call Graph to get full user details (/users/{user-id}).
  • Action: Trigger an Azure Automation runbook to enable Enterprise Voice.

Flow Summary

Here’s the full flow, start to finish:
  • Entra ID Group Membership Changes
    • A user is added to or removed from a group like “Teams Voice Users.”
  • Graph API Subscription Detects the Change
    • You’ve subscribed to /groups/{group-id}/members with changeType: "updated".
  • Graph Sends a Notification
    • A POST hits your Logic App’s HTTP trigger with metadata like resourceData.id.
  • Logic App is TriggeredValidates clientState (optional but smart).
    • Extracts the user-id.
    • Calls Graph to get full user details.
  • Triggers the runbook to take action (enable EV, log, alert, etc.).

Note: Logic Apps don’t poll Entra ID. They rely on Graph’s webhook notifications. The subscription is the middleman that makes this reactive and efficient.

Sample Code: Creating the Subscription

Here’s a generic PowerShell snippet to create the subscription:
# Step 0: Auth setup
$tenantId = "<your-tenant-id>"
$clientId = "<your-client-id>"
$clientSecret = "<your-client-secret>"
$scope = "https://graph.microsoft.com/.default"

# Get token
$body = @{
    grant_type    = "client_credentials"
    client_id     = $clientId
    client_secret = $clientSecret
    scope         = $scope
}

$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Method POST -Body $body
$accessToken = $tokenResponse.access_token

# Step 1: Create the subscription
$subscriptionBody = @{
    changeType          = "updated"
    notificationUrl     = "https://yourlogicapp.azurewebsites.net/api/notify"
    resource            = "/groups/{group-id}/members"
    expirationDateTime  = (Get-Date).AddHours(1).ToString("yyyy-MM-ddTHH:mm:ssZ")
    clientState         = "secretSauce123"
} | ConvertTo-Json -Depth 3

$response = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/subscriptions" `
    -Headers @{ Authorization = "Bearer $accessToken" } `
    -Method POST `
    -Body $subscriptionBody `
    -ContentType "application/json"

$response


In the full deployment guide, I’ll include an additional runbook designed to run independently on a scheduled basis—separate from the Logic App trigger. This daily run ensures that the Graph subscription remains active and properly connected to the Logic App. It’s a critical step, as the subscription must be able to communicate with the Logic App endpoint to deliver notifications reliably.

Permissions Matter

To make this work, your Logic App must be exposed as an Enterprise Application so you can assign the right API permissions—namely User.Read.All and Group.ReadWrite.All. I’ll cover this in more detail in the deployment guide.

The Logic App: Lightweight, Serverless, and Smarter Than It Looks

If you’ve worked with Power Automate (formerly known as Flow), Azure Logic Apps will feel familiar. Think of them as the grown-up, serverless cousin—deployed under a consumption plan, stateless, and built to handle logic flows with minimal overhead.

In our case, the Logic App is triggered by an HTTP POST from the Microsoft Graph subscription. It’s the always-on listener that springs into action when someone joins (or leaves) our Entra ID group.

Despite being lightweight, Logic Apps are surprisingly robust. They’re great at making decisions, branching logic, and calling downstream services—like our Azure Automation runbook.

Here’s what we needed our Logic App to handle:

  1. Respond to Graph’s Token Validation
    • When you first create a Graph subscription, Microsoft sends a validation request to your notificationUrl. Your Logic App needs to recognize this and respond with the validationToken to complete the handshake. No token, no subscription.
  2. Handle Membership Deltas (Adds and Removes)
    • Graph sends a notification whenever group membership changes. That could mean one user or several. Your Logic App needs to:
      • Iterate through the payload (which might be a single user or an array).
      • Identify each user’s ID.
      • Decide what to do next.
  3. Ignore Removals, Focus on Adds
    • We don’t need to trigger the runbook when a user is removed from the group. Most orgs handle license and policy cleanup separately, and we’re not trying to disable Enterprise Voice here—just enable it.
    • So we added logic to:
      • Filter out removes.
      • Only process adds.
This keeps the automation focused and avoids unnecessary runbook executions.
When spinning up your Logic App, the first decision is the hosting plan. For this use case, Consumption is the way to go. It’s serverless, stateless, and perfect for low-volume, event-driven workflows—like ours, which only fires when Graph sends a webhook.

Once deployed, you’ll land in the Azure Portal’s Logic App Designer. If you’ve used Power Automate before, this will feel familiar: a visual drag-and-drop interface for building workflows. Prefer code? You can switch to the JSON view, which is especially handy when working with Copilot to craft precise expressions and control flow logic.

Whether you’re clicking or coding, the goal is the same: build a lightweight, reactive app that listens for Graph events and kicks off the right automation—without overcomplicating things.

Here’s a common pitfall: don’t assume that a True condition always means “run the automation” and False means “don’t.” It’s not that binary.

In our Logic App, the flow is designed to evaluate multiple conditions before ultimately reaching the step that triggers the HTTP webhook to the runbook. So while the final condition must evaluate to True to proceed, earlier branches might also return True or False depending on what you're filtering for - like whether the payload includes a validationToken, or if the user action was an add vs. a remove.

In the upcoming deployment guide, I’ll include the full JSON view of the Logic App so you can see exactly how the expressions are structured. It’s not exactly human-readable prose—it’s written in Azure Logic Apps’ Workflow Definition Language (WDL), which takes some getting used to. But once you understand the flow, it becomes much easier to debug and extend.


What’s Next?

I’ll be publishing the full deployment guide and scripts to GitHub soon—both for my client and for the many others who’ve asked for this kind of automation. Hopefully, it saves you from the same toe-stubbing I ran into.

Final Thoughts

This project reminded me that automation isn’t just about writing scripts—it’s about designing resilient systems that handle real-world messiness. And sometimes, that means multiple trips to the hardware store. It took a few iterations to get things optimized.

If you’re building something similar - or want to - stay tuned for more details and code snippets. Just don’t ask me to debug your webhook at 2 a.m.


Prologue: The Prompt That Prompted Too Much


I saved this part for the end because, well, it’s funny in hindsight. What I didn’t mention earlier was my actual first iteration. I sat down, opened GitHub Copilot, and figured I’d just “talk it out” to get the creative juices flowing. My prompt?

“I would like to start a project to automate Enterprise Voice enablement for Teams Phone, based on security group membership. Please help with initial architecture concepts.”


Sounds reasonable, right?

I had Agentic mode enabled. Ten minutes later, I had 32 files across 26 directories—including .bat files and shell scripts to spin up a local Java app on my laptop. It was like asking for a sandwich recipe and getting a blueprint for a deli franchise.

Lesson learned: prompt engineering is real. Ask a vague question, get a very enthusiastic answer. Ask a precise question, get something you can actually use.


Deployment guidance coming in the next post later this week. Enjoy for now.

No comments:

Post a Comment

Azure Automation for Shared Calling Enablement

Automating Enterprise Voice Enablement for Teams Shared Calling: A Journey in Iteration This one’s a long read—because the work was iterativ...